diff --git a/apps/docs/app/routes/TryRoute.res b/apps/docs/app/routes/TryRoute.res index 22785fe5b..01acc9d54 100644 --- a/apps/docs/app/routes/TryRoute.res +++ b/apps/docs/app/routes/TryRoute.res @@ -5,50 +5,10 @@ type stylesheet = {rel: string, href: string} let links = () => [{rel: "stylesheet", href: playgroundCss}] -type props = { - bundleBaseUrl: string, - versions: array, -} - -let loader = async () => { - let (bundleBaseUrl, versionsBaseUrl) = switch ( - Node.Process.Env.playgroundBundleEndpoint, - Node.Process.Env.nodeEnv, - ) { - | (Some(baseUrl), _) => (baseUrl, baseUrl) - | (None, "development") => { - // Use remote bundles in dev - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - } - | (None, _) => ( - // Use same-origin requests for the bundle - "/playground-bundles", - // There is no version endpoint in the build phase - "https://cdn.rescript-lang.org", - ) - } +type props = CompilerData.t - try { - let versions = { - let response = await fetch(versionsBaseUrl + "/playground-bundles/versions.json") - let json = await WebAPI.Response.json(response) - json - ->JSON.Decode.array - ->Option.getOrThrow - ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) - } +let loader = () => CompilerData.load(~location=SameOriginInProduction) - Some({ - bundleBaseUrl, - versions, - }) - } catch { - | JsExn(e) => - Console.error2("error while fetching compiler versions", e) - None - } -} module ClientOnly = { @react.component let make = (~bundleBaseUrl, ~versions) => { @@ -59,7 +19,7 @@ module ClientOnly = { } let default = () => { - let data = ReactRouter.useLoaderData() + let data: option = ReactRouter.useLoaderData() <> array -type props = { - bundleBaseUrl: string, - versions: array, -} +type props = CompilerData.t let loader: unit => promise> diff --git a/apps/docs/scripts/__tests__/playground-shared.test.js b/apps/docs/scripts/__tests__/playground-shared.test.js new file mode 100644 index 000000000..ab3585c7b --- /dev/null +++ b/apps/docs/scripts/__tests__/playground-shared.test.js @@ -0,0 +1,289 @@ +import { runInNewContext } from "node:vm"; +import { parse } from "@babel/parser"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as Babel from "../../../../packages/shared/src/Babel.jsx"; +import * as CompilerData from "../../../../packages/shared/src/CompilerData.jsx"; +import * as CompilerRuntimeImport from "../../../../packages/shared/src/CompilerRuntimeImport.jsx"; +import * as CompilerVersions from "../../../../packages/shared/src/CompilerVersions.jsx"; +import * as RuntimeConsole from "../../../../packages/shared/src/RuntimeConsole.jsx"; +import * as Semver from "../../../../packages/shared/src/Semver.jsx"; +import * as GuideCompilerData from "../../../guide/app/GuideCompilerData.jsx"; +import * as GuideCompilerSettings from "../../../guide/app/GuideCompilerSettings.jsx"; +import * as GuideRuntimeImport from "../../../guide/app/GuideRuntimeImport.jsx"; +import * as GuideRuntimeTransform from "../../../guide/app/GuideRuntimeTransform.jsx"; +import { CdnMeta } from "../../../../packages/playground/src/CompilerManagerHook.jsx"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("compiler versions", () => { + test("sorts numeric version components and prerelease identifiers", () => { + const versions = [ + "v12.9.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.10.0-alpha.10", + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + ]; + expect(CompilerVersions.supported(versions).map(Semver.toString)).toEqual([ + "v12.10.0", + "v12.10.0-rc.1", + "v12.10.0-beta.1", + "v12.10.0-alpha.10", + "v12.10.0-alpha.2", + "v12.9.11", + "v12.9.10", + ]); + expect( + Semver.compare(Semver.parse("v12.10.0"), Semver.parse("v12.10.0")), + ).toBe(0); + expect( + CompilerVersions.latestStable(CompilerVersions.supported(versions)), + ).toEqual(Semver.parse("v12.10.0")); + }); + + test("keeps the supported compiler range and ignores invalid versions", () => { + expect( + CompilerVersions.supported([ + "invalid", + "v9.1.0", + "v10.0.1", + "v10.1.0", + "v11.1.3", + "v11.1.4", + "v11.2.0-beta.2", + "v11.2.0", + "v12.1.0-alpha.1", + "v12.1.0", + "v12.2.0-alpha.1", + "v13.0.0-alpha.1", + ]).map(Semver.toString), + ).toEqual([ + "v13.0.0-alpha.1", + "v12.2.0-alpha.1", + "v12.1.0", + "v11.2.0", + "v11.1.4", + "v10.1.0", + ]); + }); +}); + +describe("runtime imports", () => { + test.each([ + ["v12.3.1", "./stdlib/Stdlib_Array.js", "v12.3.1", "Stdlib_Array.js"], + [ + "v13.0.0-alpha.1", + "./stdlib/Stdlib_Array.mjs", + "v13.0.0-alpha.1", + "Stdlib_Array.js", + ], + [ + "v12.0.0-alpha.7", + "./stdlib/core__Array.js", + "v12.0.0-alpha.9", + "Array.js", + ], + [ + "v12.0.0-alpha.8", + "./stdlib/core__Array.js", + "v12.0.0-alpha.9", + "core__Array.js", + ], + ["v11.1.4", "./stdlib/core__Array.js", "v11.2.0-beta.2", "Core__Array.js"], + ])( + "maps %s imports consistently in both apps", + (version, path, runtimeVersion, filename) => { + const compilerVersion = Semver.parse(version); + const expected = `https://bundles.test/${runtimeVersion}/compiler-builtins/stdlib/${filename}`; + expect( + CompilerRuntimeImport.url( + "https://bundles.test", + compilerVersion, + path, + ), + ).toBe(expected); + expect( + GuideRuntimeImport.url("https://bundles.test", compilerVersion, path), + ).toBe(expected); + expect( + CdnMeta.getStdlibRuntimeUrl( + "https://bundles.test", + Semver.parse(runtimeVersion), + filename, + ), + ).toBe(expected); + }, + ); + + test("both transforms collect namespace imports without accepting lookalike paths", () => { + const source = ` + import * as Arrays from "./stdlib/Stdlib_Array.js"; + import * as Other from "./stdlib-other/Other.js"; + import Default from "./stdlib/Default.js"; + import { value } from "./stdlib/Named.js"; + import "./stdlib/SideEffect.js"; + import * as React from "react"; + const answer = Arrays.length([]); + export { answer }; + `; + const expected = { Arrays: "./stdlib/Stdlib_Array.js" }; + expect( + Babel.RuntimeImports.collect(parse(source, { sourceType: "module" })), + ).toEqual(expected); + const playground = Babel.PlaygroundValidator.validate( + parse(source, { sourceType: "module" }), + ); + const guide = GuideRuntimeTransform.transform(undefined, source); + expect(playground.imports).toEqual(expected); + expect(guide.imports).toEqual(expected); + expect(playground.code).not.toMatch(/\b(import|export)\b/); + expect(guide.code).not.toMatch(/\b(import|export)\b/); + }); + + test("preserves the playground React entry point detection", () => { + const source = + "function Playground$App() {} const App = {make: Playground$App}; export {App};"; + expect( + Babel.PlaygroundValidator.validate( + parse(source, { sourceType: "module" }), + ).entryPointExists, + ).toBe(true); + }); +}); + +describe("console bridge", () => { + test("serializes iframe logs for the shared decoder, including null", () => { + const messages = []; + const sandbox = { + console: {}, + parent: { + window: { + postMessage: (message, origin) => messages.push({ message, origin }), + }, + }, + }; + runInNewContext(RuntimeConsole.bridgeScript, sandbox); + runInNewContext( + `console.log(null, undefined, {answer: 42}, 5, true); console.warn("warning"); console.error("error")`, + sandbox, + ); + expect(messages.map(({ origin }) => origin)).toEqual(["*", "*", "*"]); + expect( + messages.map(({ message }) => RuntimeConsole.fromMessage(message)), + ).toEqual([ + { + level: "log", + content: ["null", "undefined", '{"answer":42}', "5", "true"], + }, + { level: "warn", content: ["warning"] }, + { level: "error", content: ["error"] }, + ]); + expect( + RuntimeConsole.text(RuntimeConsole.fromMessage(messages[0].message)), + ).toBe('null undefined {"answer":42} 5 true'); + }); + + test.each([ + null, + [], + "message", + {}, + { type: "other", args: [] }, + { type: "log", args: "text" }, + { type: "log", args: [null] }, + { type: "log", args: [{}] }, + ])("ignores malformed or unrelated messages: %j", (message) => { + expect(RuntimeConsole.fromMessage(message)).toBeUndefined(); + }); +}); + +describe("compiler metadata", () => { + test("uses same-origin bundles in production, the CDN in development, and explicit overrides", () => { + expect( + CompilerData.endpoints("SameOriginInProduction", "production", undefined), + ).toEqual({ + bundleBaseUrl: "/playground-bundles", + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect( + CompilerData.endpoints( + "SameOriginInProduction", + "development", + undefined, + ), + ).toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect(CompilerData.endpoints("Remote", "production", undefined)).toEqual({ + bundleBaseUrl: CompilerData.cdnBaseUrl, + versionsBaseUrl: CompilerData.cdnBaseUrl, + }); + expect( + CompilerData.endpoints( + "SameOriginInProduction", + "production", + "https://override.test", + ), + ).toEqual({ + bundleBaseUrl: "https://override.test", + versionsBaseUrl: "https://override.test", + }); + }); + + test("fetches metadata from the override without changing the guide pin", async () => { + vi.stubEnv("PLAYGROUND_BUNDLE_ENDPOINT", "https://override.test"); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(["v13.0.0", "v12.3.1"]))); + vi.stubGlobal("fetch", fetchMock); + expect(await CompilerData.load("SameOriginInProduction")).toEqual({ + bundleBaseUrl: "https://override.test", + versions: ["v13.0.0", "v12.3.1"], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://override.test/playground-bundles/versions.json", + ); + expect(GuideCompilerData.load()).toEqual({ + bundleBaseUrl: "https://override.test", + }); + expect(GuideCompilerSettings.version).toBe("v12.3.1"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("rejects an HTTP error even if its body looks like metadata", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("[]", { status: 503 })), + ); + await expect( + CompilerData.fetchVersions("https://bundles.test"), + ).rejects.toThrow("HTTP 503"); + }); + + test.each(["{", "{}", '["v12.3.1", 123]'])( + "rejects invalid metadata: %s", + async (body) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body))); + await expect( + CompilerData.fetchVersions("https://bundles.test"), + ).rejects.toBeDefined(); + }, + ); + + test("returns the loader's failure state when the network is unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await CompilerData.load("SameOriginInProduction")).toBeUndefined(); + expect(error).toHaveBeenCalledWith( + "error while fetching compiler versions", + expect.any(Error), + ); + }); +}); diff --git a/apps/docs/vitest.scripts.config.mjs b/apps/docs/vitest.scripts.config.mjs index adecde336..ec7d1f18f 100644 --- a/apps/docs/vitest.scripts.config.mjs +++ b/apps/docs/vitest.scripts.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["scripts/__tests__/*.test.jsx"], + include: ["scripts/__tests__/*.test.{jsx,js}"], expect: { requireAssertions: true }, }, }); diff --git a/apps/guide/__tests__/GuideCompilerSettings_.test.res b/apps/guide/__tests__/GuideCompilerSettings_.test.res index 853643400..a902d271d 100644 --- a/apps/guide/__tests__/GuideCompilerSettings_.test.res +++ b/apps/guide/__tests__/GuideCompilerSettings_.test.res @@ -1,18 +1,8 @@ open Vitest -test("selects the latest stable ReScript compiler with ESM output", async () => { - let version = - GuideCompilerSettings.latestStableVersion([ - "v12.1.0-alpha.1", - "v11.1.4", - "v12.0.0", - "v12.2.0-beta.1", - "v12.1.3", - ]) - ->Option.getOrThrow - ->Semver.toString - - expect(version)->toBe("v12.1.3") +test("pins the guide to its validated v12 compiler with ESM output", async () => { + expect(GuideCompilerSettings.version)->toBe("v12.3.1") + expect(GuideCompilerSettings.parsedVersion->Semver.toString)->toBe(GuideCompilerSettings.version) expect(GuideCompilerSettings.moduleSystem)->toBe("esmodule") expect(GuideCompilerSettings.warnFlags->String.includes("-109"))->toBe(true) }) diff --git a/apps/guide/app/GuideCompilerBridge.res b/apps/guide/app/GuideCompilerBridge.res index 18072bd76..fe0698018 100644 --- a/apps/guide/app/GuideCompilerBridge.res +++ b/apps/guide/app/GuideCompilerBridge.res @@ -1,18 +1,11 @@ @react.component let make = ( ~bundleBaseUrl, - ~versions: array, ~code, ~editorRef: React.ref>, ~setOutput, ) => { - GuideCompilerBridgeHook.useCompilerBridge( - ~bundleBaseUrl, - ~versions, - ~code, - ~editorRef, - ~setOutput, - ) + GuideCompilerBridgeHook.useCompilerBridge(~bundleBaseUrl, ~code, ~editorRef, ~setOutput)
diff --git a/apps/guide/app/GuideCompilerBridgeHook.res b/apps/guide/app/GuideCompilerBridgeHook.res index 2b19d1a1b..c8c508eb5 100644 --- a/apps/guide/app/GuideCompilerBridgeHook.res +++ b/apps/guide/app/GuideCompilerBridgeHook.res @@ -1,22 +1,14 @@ let useCompilerBridge = ( ~bundleBaseUrl, - ~versions, ~code, ~editorRef: React.ref>, ~setOutput, ) => { - let compilerVersions = React.useMemo( - () => GuideCompilerSettings.supportedVersions(versions), - [versions], - ) - let initialVersion = React.useMemo( - () => compilerVersions->GuideCompilerSettings.latestStableParsedVersion, - [compilerVersions], - ) + let compilerVersions = React.useMemo(() => [GuideCompilerSettings.parsedVersion], []) let (compilerState, compilerDispatch) = CompilerManagerHook.useCompilerManager( ~bundleBaseUrl, - ~initialVersion?, + ~initialVersion=GuideCompilerSettings.parsedVersion, ~initialModuleSystem=GuideCompilerSettings.moduleSystem, ~initialWarnFlags=GuideCompilerSettings.warnFlags, ~syncUrl=false, @@ -27,6 +19,27 @@ let useCompilerBridge = ( let lastExecutedJsCode = React.useRef("") let isWaitingForRuntimeOutput = React.useRef(false) + React.useEffect(() => { + switch compilerState { + | Init => + let timer = setTimeout( + ~handler=() => + setOutput( + _ => + GuideCompilerFeedback.Output.make( + ~status="Compiler setup failed", + ~diagnostics=[ + `ReScript ${GuideCompilerSettings.version} did not load within 15 seconds. Reload the page to try again.`, + ], + ), + ), + ~timeout=GuideCompilerSettings.loadingTimeoutMs, + ) + Some(() => clearTimeout(timer)) + | _ => None + } + }, (compilerState, setOutput)) + React.useEffect(() => { switch compilerState { | Ready({targetLang}) if code !== lastCompiledCode.current => @@ -39,29 +52,15 @@ let useCompilerBridge = ( } }, (code, compilerState, compilerDispatch)) - React.useEffect(() => { - let cb = event => { - let data = event["data"] - let appendLog = (level, content) => { - let runtimeLog = {GuideCompilerFeedback.Output.level, content} - if isWaitingForRuntimeOutput.current { - isWaitingForRuntimeOutput.current = false - setOutput(_ => runtimeLog->GuideCompilerFeedback.Output.fromRuntimeLog) - } else { - setOutput(output => output->GuideCompilerFeedback.Output.withRuntimeLog(runtimeLog)) - } - } - - switch data["type"] { - | #log => appendLog(#log, data["args"]) - | #warn => appendLog(#warn, data["args"]) - | #error => appendLog(#error, data["args"]) - | _ => () - } + let onLog = React.useCallback(runtimeLog => { + if isWaitingForRuntimeOutput.current { + isWaitingForRuntimeOutput.current = false + setOutput(_ => runtimeLog->GuideCompilerFeedback.Output.fromRuntimeLog) + } else { + setOutput(output => output->GuideCompilerFeedback.Output.withRuntimeLog(runtimeLog)) } - WebAPI.Window.addEventListener(window, Custom("message"), cb) - Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), cb)) }, [setOutput]) + RuntimeConsole.useLogs(onLog) React.useEffect(() => { let feedback = compilerState->GuideCompilerFeedback.editorFeedbackFromState @@ -83,18 +82,11 @@ let useCompilerBridge = ( | Ready({selected, result: Comp(Success({jsCode, typeHints}))}) if jsCode !== lastExecutedJsCode.current => lastExecutedJsCode.current = jsCode - let runtimeJsCode = switch GuideRuntimeSource.instrument(~code, ~typeHints) { - | Some(runtimeCode) => - // The instrumented source may fail on compiler internals; fall back to user JS in that case. - switch selected.instance->RescriptCompilerApi.Compiler.resCompile(runtimeCode) { - | Success({jsCode}) => jsCode - | Fail(_) | UnexpectedError(_) | Unknown(_, _) => jsCode - } - | None => jsCode - } - - switch runtimeJsCode->GuideRuntimeTransform.transform( - ~resultBindingName=GuideRuntimeSource.resultBindingName, + switch GuideRuntimeProgram.fromCompilation( + ~compiler=selected.instance, + ~code, + ~jsCode, + ~typeHints, ) { | Some({code: runtimeCode, imports}) => isWaitingForRuntimeOutput.current = true diff --git a/apps/guide/app/GuideCompilerData.res b/apps/guide/app/GuideCompilerData.res index d4a32be91..0bfb0d297 100644 --- a/apps/guide/app/GuideCompilerData.res +++ b/apps/guide/app/GuideCompilerData.res @@ -1,43 +1,3 @@ -type t = { - bundleBaseUrl: string, - versions: array, -} +type t = {bundleBaseUrl: string} -module Env = { - @scope(("process", "env")) external nodeEnv: string = "NODE_ENV" - @scope(("process", "env")) - external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" -} - -let fetchVersions = async versionsBaseUrl => { - let response = await fetch(versionsBaseUrl ++ "/playground-bundles/versions.json") - let json = await WebAPI.Response.json(response) - json - ->JSON.Decode.array - ->Option.getOrThrow - ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) -} - -let load = async () => { - let (bundleBaseUrl, versionsBaseUrl) = switch (Env.playgroundBundleEndpoint, Env.nodeEnv) { - | (Some(baseUrl), _) => (baseUrl, baseUrl) - | (None, "development") => - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - | (None, _) => - let baseUrl = "https://cdn.rescript-lang.org" - (baseUrl, baseUrl) - } - - try { - let versions = await fetchVersions(versionsBaseUrl) - Some({ - bundleBaseUrl, - versions, - }) - } catch { - | JsExn(error) => - Console.error2("error while fetching guide compiler versions", error) - None - } -} +let load = () => {bundleBaseUrl: CompilerEnvironment.remoteBundleBaseUrl()} diff --git a/apps/guide/app/GuideCompilerFeedback.res b/apps/guide/app/GuideCompilerFeedback.res index 2fd329729..c70e7950c 100644 --- a/apps/guide/app/GuideCompilerFeedback.res +++ b/apps/guide/app/GuideCompilerFeedback.res @@ -1,12 +1,8 @@ module Api = RescriptCompilerApi module Output = { - type level = [ - | #log - | #warn - | #error - ] - type runtimeLog = {level: level, content: array} + type level = RuntimeConsole.level + type runtimeLog = RuntimeConsole.log = {level: level, content: array} type t = { status: string, diff --git a/apps/guide/app/GuideCompilerSettings.res b/apps/guide/app/GuideCompilerSettings.res index 843d36835..52bdebc67 100644 --- a/apps/guide/app/GuideCompilerSettings.res +++ b/apps/guide/app/GuideCompilerSettings.res @@ -1,47 +1,6 @@ +// Upgrade deliberately, together with the lesson validation run. +let version = "v12.3.1" +let parsedVersion = version->Semver.parse->Option.getOrThrow let moduleSystem = "esmodule" let warnFlags = "+a-4-9-20-40-41-42-50-61-102-109" - -let isSupportedVersion = (version: Semver.t) => - switch version.major { - | 8 | 9 => false - | 10 => version.minor >= 1 - | 11 => version.minor >= 1 && version.preRelease->Option.isNone - | 12 => - switch version.preRelease { - | None => true - | Some(_) => version.minor > 1 - } - | _ => true - } - -let compareIntDescending = (a, b) => { - if a > b { - -1.0 - } else if a < b { - 1.0 - } else { - 0.0 - } -} - -let compareVersionDescending = (a: Semver.t, b: Semver.t) => { - switch compareIntDescending(a.major, b.major) { - | 0.0 => - switch compareIntDescending(a.minor, b.minor) { - | 0.0 => compareIntDescending(a.patch, b.patch) - | result => result - } - | result => result - } -} - -let supportedVersions = versions => - versions - ->Array.filterMap(Semver.parse) - ->Array.filter(isSupportedVersion) - ->Array.toSorted(compareVersionDescending) - -let latestStableParsedVersion = (versions: array) => - versions->Array.find(version => version.preRelease->Option.isNone) - -let latestStableVersion = versions => versions->supportedVersions->latestStableParsedVersion +let loadingTimeoutMs = 15000 diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res index 250e6dbaf..5d0197970 100644 --- a/apps/guide/app/GuideHome.res +++ b/apps/guide/app/GuideHome.res @@ -128,13 +128,9 @@ let make = (
{switch compilerData { - | Some({bundleBaseUrl, versions}) => + | Some({bundleBaseUrl}) => | None => React.null }} diff --git a/apps/guide/app/GuideHomeRoute.res b/apps/guide/app/GuideHomeRoute.res index 1f36b5179..240ce08c6 100644 --- a/apps/guide/app/GuideHomeRoute.res +++ b/apps/guide/app/GuideHomeRoute.res @@ -1,12 +1,12 @@ type loaderData = { - compilerData: option, + compilerData: GuideCompilerData.t, lessons: array, } exception LessonLoadError(string) let loader: ReactRouter.Loader.t = async _ => { - let compilerData = await GuideCompilerData.load() + let compilerData = GuideCompilerData.load() let lessons = switch GuideLessonContent.load() { | Ok(lessons) => lessons | Error(message) => throw(LessonLoadError(message)) @@ -18,5 +18,5 @@ let loader: ReactRouter.Loader.t = async _ => { @react.component let default = () => { let {compilerData, lessons}: loaderData = ReactRouter.useLoaderData() - + } diff --git a/apps/guide/app/GuideHomeRoute.resi b/apps/guide/app/GuideHomeRoute.resi index 380c80ddc..321b35251 100644 --- a/apps/guide/app/GuideHomeRoute.resi +++ b/apps/guide/app/GuideHomeRoute.resi @@ -1,5 +1,5 @@ type loaderData = { - compilerData: option, + compilerData: GuideCompilerData.t, lessons: array, } diff --git a/apps/guide/app/GuideLesson.res b/apps/guide/app/GuideLesson.res index e6630824e..7c23526da 100644 --- a/apps/guide/app/GuideLesson.res +++ b/apps/guide/app/GuideLesson.res @@ -53,7 +53,7 @@ let hasPreviousLesson = index => index > 0 let hasNextLesson = (~lessons, index) => index < lessons->Array.length - 1 let runtimeLogText = (runtimeLog: GuideCompilerFeedback.Output.runtimeLog) => - runtimeLog.content->Array.join(" ") + RuntimeConsole.text(runtimeLog) let isExerciseComplete = (~exercise, ~output: GuideCompilerFeedback.Output.t) => switch exercise.check { diff --git a/apps/guide/app/GuideRuntimeImport.res b/apps/guide/app/GuideRuntimeImport.res index dac45a148..38588c39b 100644 --- a/apps/guide/app/GuideRuntimeImport.res +++ b/apps/guide/app/GuideRuntimeImport.res @@ -1,45 +1 @@ -let capitalizeFirstLetter = string => { - let firstLetter = string->String.charAt(0)->String.toUpperCase - `${firstLetter}${string->String.slice(~start=1)}` -} - -let filenameForCompiler = (~compilerVersion: Semver.t, path) => { - let filename = path->String.slice(~start=9) - switch compilerVersion { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => - let filename = if filename->String.startsWith("core__") { - filename->String.slice(~start=6) - } else { - filename - } - filename->capitalizeFirstLetter - | {major} if major < 12 && filename->String.startsWith("core__") => - filename->capitalizeFirstLetter - | _ => filename - } -} - -let compilerVersionForRuntimeImport = (compilerVersion: Semver.t) => - // Older compiler builds emitted stdlib import paths that no longer match the CDN layout. - switch compilerVersion { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { - Semver.major: 12, - minor: 0, - patch: 0, - preRelease: Some(Alpha(9)), - } - | {major, minor} if (major === 11 && minor < 2) || major < 11 => { - major: 11, - minor: 2, - patch: 0, - preRelease: Some(Beta(2)), - } - | version => version - } - -let url = (~bundleBaseUrl, ~compilerVersion: Semver.t, path) => { - let filename = path->filenameForCompiler(~compilerVersion) - let compilerVersion = compilerVersion->compilerVersionForRuntimeImport - - CompilerManagerHook.CdnMeta.getStdlibRuntimeUrl(bundleBaseUrl, compilerVersion, filename) -} +include CompilerRuntimeImport diff --git a/apps/guide/app/GuideRuntimeProgram.res b/apps/guide/app/GuideRuntimeProgram.res new file mode 100644 index 000000000..c6a6b4a6e --- /dev/null +++ b/apps/guide/app/GuideRuntimeProgram.res @@ -0,0 +1,15 @@ +// Keep lesson validation and browser execution on the same transformation path. +let fromCompilation = (~compiler, ~code, ~jsCode, ~typeHints) => { + let runtimeJsCode = switch GuideRuntimeSource.instrument(~code, ~typeHints) { + | Some(runtimeCode) => + switch compiler->RescriptCompilerApi.Compiler.resCompile(runtimeCode) { + | Success({jsCode}) => jsCode + | Fail(_) | UnexpectedError(_) | Unknown(_, _) => jsCode + } + | None => jsCode + } + + runtimeJsCode->GuideRuntimeTransform.transform( + ~resultBindingName=GuideRuntimeSource.resultBindingName, + ) +} diff --git a/apps/guide/app/GuideRuntimeTransform.res b/apps/guide/app/GuideRuntimeTransform.res index 3163f6b00..c8fbadbe3 100644 --- a/apps/guide/app/GuideRuntimeTransform.res +++ b/apps/guide/app/GuideRuntimeTransform.res @@ -10,21 +10,6 @@ let isModuleBoundary = statement => { } } -let collectRuntimeImport = (~imports, statement) => { - switch statement->Babel.Ast.nodeType { - | "ImportDeclaration" => - let sourceValue = statement->Babel.Ast.source->Babel.Ast.stringLiteralValue - if sourceValue->String.startsWith("./stdlib") { - switch statement->Babel.Ast.specifiers { - | [specifier] => - imports->Dict.set(specifier->Babel.Ast.specifierLocal->Babel.Ast.lvalName, sourceValue) - | _ => () - } - } - | _ => () - } -} - let consoleLogStatement = expression => { let consoleLog = Babel.Types.memberExpression( Babel.Types.identifier("console"), @@ -95,8 +80,7 @@ let appendLastBindingLog = body => let transform = (~resultBindingName=?, jsCode) => try { let ast = Babel.Parser.parse(jsCode, {sourceType: "module"}) - let imports = Dict.make() - ast.program.body->Array.forEach(statement => statement->collectRuntimeImport(~imports)) + let imports = Babel.RuntimeImports.collect(ast) let executableBody = ast.program.body->Array.filter(statement => !isModuleBoundary(statement)) diff --git a/packages/playground/src/CompilerManagerHook.res b/packages/playground/src/CompilerManagerHook.res index 1b3f35c53..17452c290 100644 --- a/packages/playground/src/CompilerManagerHook.res +++ b/packages/playground/src/CompilerManagerHook.res @@ -41,15 +41,7 @@ module CdnMeta = { let getLibraryCmijUrl = (baseUrl, version, libraryName: string): string => `${baseUrl}/${Semver.toString(version)}/${libraryName}/cmij.js` - let getStdlibRuntimeUrl = (baseUrl, version, filename) => { - // The compiler emits .mjs imports, while the published runtime bundles use .js. - let filename = if filename->String.endsWith(".mjs") { - filename->String.slice(~start=0, ~end=-4) ++ ".js" - } else { - filename - } - `${baseUrl}/${Semver.toString(version)}/compiler-builtins/stdlib/${filename}` - } + let getStdlibRuntimeUrl = CompilerRuntimeImport.stdlibUrl } module FinalResult = { @@ -150,11 +142,6 @@ let wrapReactApp = code => window.reactRoot.render(React.createElement(App.make, {})); })();` -let capitalizeFirstLetter = string => { - let firstLetter = string->String.charAt(0)->String.toUpperCase - `${firstLetter}${string->String.slice(~start=1)}` -} - type error = | SetupError(string) | CompilerLoadingError(string) @@ -595,37 +582,10 @@ let useCompilerManager = ( let ast = Parser.parse(jsCode, {sourceType: "module"}) let {entryPointExists, code, imports} = PlaygroundValidator.validate(ast) - let imports = imports->Dict.mapValues(path => { - let filename = path->String.slice(~start=9) // the part after "./stdlib/" - let filename = switch state.selected.id { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => - let filename = if filename->String.startsWith("core__") { - filename->String.slice(~start=6) - } else { - filename - } - capitalizeFirstLetter(filename) - | {major} if major < 12 && filename->String.startsWith("core__") => - capitalizeFirstLetter(filename) - | _ => filename - } - let compilerVersion = switch state.selected.id { - | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { - Semver.major: 12, - minor: 0, - patch: 0, - preRelease: Some(Alpha(9)), - } - | {major, minor} if (major === 11 && minor < 2) || major < 11 => { - major: 11, - minor: 2, - patch: 0, - preRelease: Some(Beta(2)), - } - | version => version - } - CdnMeta.getStdlibRuntimeUrl(bundleBaseUrl, compilerVersion, filename) - }) + let imports = + imports->Dict.mapValues(path => + CompilerRuntimeImport.url(~bundleBaseUrl, ~compilerVersion=state.selected.id, path) + ) entryPointExists ? code->wrapReactApp->EvalIFrame.sendOutput(imports) diff --git a/packages/playground/src/ConsolePanel.res b/packages/playground/src/ConsolePanel.res index 0c9bff4ed..238fd9e94 100644 --- a/packages/playground/src/ConsolePanel.res +++ b/packages/playground/src/ConsolePanel.res @@ -1,25 +1,13 @@ -type logLevel = [ - | #log - | #warn - | #error -] -type log = {level: logLevel, content: array} +type logLevel = RuntimeConsole.level +type log = RuntimeConsole.log = {level: logLevel, content: array} @react.component let make = (~logs, ~appendLog) => { - React.useEffect(() => { - let cb = e => { - let data = e["data"] - switch data["type"] { - | #...logLevel as logLevel => - let args: array = data["args"] - appendLog(logLevel, args) - | _ => () - } - } - WebAPI.Window.addEventListener(window, Custom("message"), cb) - Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), cb)) - }, [appendLog]) + let onLog = React.useCallback( + ({RuntimeConsole.level: level, content}) => appendLog(level, content), + [appendLog], + ) + RuntimeConsole.useLogs(onLog)

{React.string("Console")}

@@ -33,17 +21,16 @@ let make = (~logs, ~appendLog) => { | logs => let content = logs - ->Array.mapWithIndex(({level: logLevel, content: log}, i) => { - let log = Array.join(log, " ") + ->Array.mapWithIndex((log, i) => {
 ""
             | #warn => "text-orange"
             | #error => "text-fire"
             }}
           >
-            {React.string(log)}
+            {React.string(RuntimeConsole.text(log))}
           
}) ->React.array diff --git a/packages/playground/src/EvalIFrame.res b/packages/playground/src/EvalIFrame.res index 3531edcf1..746697637 100644 --- a/packages/playground/src/EvalIFrame.res +++ b/packages/playground/src/EvalIFrame.res @@ -47,23 +47,7 @@ let srcDoc = ` console.error(err); } }); - const sendLog = (logLevel) => (...args) => { - let finalArgs = args.map(arg => { - if (arg === undefined) { - return 'undefined'; - } - else if (typeof arg === 'object') { - return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); - } else if (typeof arg === 'function') { - return arg.toString() - } - return arg; - }); - parent.window.postMessage({ type: logLevel, args: finalArgs }, '*'); - }; - console.log = sendLog('log'); - console.warn = sendLog('warn'); - console.error = sendLog('error'); + ${RuntimeConsole.bridgeScript} diff --git a/packages/playground/src/Playground.res b/packages/playground/src/Playground.res index 70038bc9e..8529260e8 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -1657,39 +1657,12 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { None }, []) - let versions = - versions - ->Array.filterMap(v => v->Semver.parse) - ->Array.filter(v => - switch v.major { - | 8 | 9 => false - | 10 => v.minor >= 1 - | 11 => - v.minor >= 1 && v.preRelease->Option.isNone && (v.minor == 1 && v.patch >= 4) ? true : false - | 12 => - switch v.preRelease { - | None => true - | Some(_) => v.minor > 1 - } - | _ => true - } - ) - ->Array.toSorted((a, b) => { - let cmp = ({Semver.major: major, minor, patch, _}) => { - [major, minor, patch] - ->Array.map(v => v->Int.toString) - ->Array.join("") - ->Int.fromString - ->Option.getOr(0) - } - - cmp(b) > cmp(a) ? 1.0 : -1.0 - }) + let versions = CompilerVersions.supported(versions) let initialVersion = switch versions { | [v] => Some(v) // only single version available. maybe local dev. | versions => { - let lastStableVersion = versions->Array.find(version => version.preRelease->Option.isNone) + let lastStableVersion = versions->CompilerVersions.latestStable switch Nullable.make( searchParams->WebAPI.URLSearchParams.get((CompilerManagerHook.Version :> string)), ) { diff --git a/packages/shared/src/Babel.res b/packages/shared/src/Babel.res index 561cd4eec..990e9f29d 100644 --- a/packages/shared/src/Babel.res +++ b/packages/shared/src/Babel.res @@ -127,6 +127,25 @@ module Types = { external expressionStatement: Ast.expression => Ast.statement = "expressionStatement" } +module RuntimeImports = { + let collect = ast => { + let imports = Dict.make() + ast.Ast.program.body->Array.forEach(statement => { + if statement->Ast.nodeType === "ImportDeclaration" { + let source = statement->Ast.source->Ast.stringLiteralValue + if source->String.startsWith("./stdlib/") { + switch statement->Ast.specifiers { + | [ImportNamespaceSpecifier({local: Identifier({name})})] => + imports->Dict.set(name, source) + | _ => () + } + } + } + }) + imports + } +} + module PlaygroundValidator = { type validator = { entryPointExists: bool, @@ -136,26 +155,13 @@ module PlaygroundValidator = { let validate = ast => { let entryPoint = ref(false) - let imports = Dict.make() + let imports = RuntimeImports.collect(ast) let remove = nodePath => Generator.remove(nodePath) Traverse.traverse( ast, { - "ImportDeclaration": ( - { - node: ImportDeclaration({specifiers, source: StringLiteral({value: source})}), - } as nodePath: Ast.nodePath, - ) => { - if source->String.startsWith("./stdlib") { - switch specifiers { - | [ImportNamespaceSpecifier({local: Identifier({name})})] => - imports->Dict.set(name, source) - | _ => () - } - } - remove(nodePath) - }, + "ImportDeclaration": remove, "ExportNamedDeclaration": remove, "VariableDeclaration": ( {node: VariableDeclaration({declarations})}: Ast.nodePath, diff --git a/packages/shared/src/CompilerData.res b/packages/shared/src/CompilerData.res new file mode 100644 index 000000000..074a706e8 --- /dev/null +++ b/packages/shared/src/CompilerData.res @@ -0,0 +1,49 @@ +type t = {bundleBaseUrl: string, versions: array} +type endpoints = {bundleBaseUrl: string, versionsBaseUrl: string} +type bundleLocation = Remote | SameOriginInProduction + +let cdnBaseUrl = CompilerEnvironment.cdnBaseUrl + +let endpoints = (~location, ~nodeEnv, ~override) => + switch override { + | Some(baseUrl) => {bundleBaseUrl: baseUrl, versionsBaseUrl: baseUrl} + | None => { + bundleBaseUrl: location === SameOriginInProduction && nodeEnv !== "development" + ? "/playground-bundles" + : cdnBaseUrl, + versionsBaseUrl: cdnBaseUrl, + } + } + +let configuredEndpoints = location => + endpoints( + ~location, + ~nodeEnv=CompilerEnvironment.nodeEnv, + ~override=CompilerEnvironment.playgroundBundleEndpoint, + ) + +let fetchVersions = async versionsBaseUrl => { + let response = await fetch(versionsBaseUrl ++ "/playground-bundles/versions.json") + if response.ok === false { + JsError.throwWithMessage( + `Could not fetch compiler versions from ${versionsBaseUrl}: HTTP ${response.status->Int.toString}`, + ) + } + let json = await response->WebAPI.Response.json + json + ->JSON.Decode.array + ->Option.getOrThrow + ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) +} + +let load = async (~location) => { + let {bundleBaseUrl, versionsBaseUrl} = configuredEndpoints(location) + try { + let versions = await fetchVersions(versionsBaseUrl) + Some({bundleBaseUrl, versions}) + } catch { + | JsExn(error) => + Console.error2("error while fetching compiler versions", error) + None + } +} diff --git a/packages/shared/src/CompilerEnvironment.res b/packages/shared/src/CompilerEnvironment.res new file mode 100644 index 000000000..a2e25a269 --- /dev/null +++ b/packages/shared/src/CompilerEnvironment.res @@ -0,0 +1,6 @@ +@scope(("process", "env")) external nodeEnv: string = "NODE_ENV" +@scope(("process", "env")) +external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" + +let cdnBaseUrl = "https://cdn.rescript-lang.org" +let remoteBundleBaseUrl = () => playgroundBundleEndpoint->Option.getOr(cdnBaseUrl) diff --git a/packages/shared/src/CompilerRuntimeImport.res b/packages/shared/src/CompilerRuntimeImport.res new file mode 100644 index 000000000..d39e1ea74 --- /dev/null +++ b/packages/shared/src/CompilerRuntimeImport.res @@ -0,0 +1,54 @@ +let capitalizeFirstLetter = string => { + let firstLetter = string->String.charAt(0)->String.toUpperCase + `${firstLetter}${string->String.slice(~start=1)}` +} + +let filenameForCompiler = (~compilerVersion: Semver.t, path) => { + let filename = path->String.slice(~start=9) + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => + let filename = if filename->String.startsWith("core__") { + filename->String.slice(~start=6) + } else { + filename + } + filename->capitalizeFirstLetter + | {major} if major < 12 && filename->String.startsWith("core__") => + filename->capitalizeFirstLetter + | _ => filename + } +} + +let compilerVersionForRuntimeImport = (compilerVersion: Semver.t) => + // Older compiler builds emitted stdlib import paths that no longer match the CDN layout. + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { + Semver.major: 12, + minor: 0, + patch: 0, + preRelease: Some(Alpha(9)), + } + | {major, minor} if (major === 11 && minor < 2) || major < 11 => { + major: 11, + minor: 2, + patch: 0, + preRelease: Some(Beta(2)), + } + | version => version + } + +let stdlibUrl = (baseUrl, version, filename) => { + // Published runtime bundles use .js even when the compiler emits .mjs imports. + let filename = + filename->String.endsWith(".mjs") + ? filename->String.slice(~start=0, ~end=-4) ++ ".js" + : filename + `${baseUrl}/${Semver.toString(version)}/compiler-builtins/stdlib/${filename}` +} + +let url = (~bundleBaseUrl, ~compilerVersion: Semver.t, path) => { + let filename = path->filenameForCompiler(~compilerVersion) + let compilerVersion = compilerVersion->compilerVersionForRuntimeImport + + stdlibUrl(bundleBaseUrl, compilerVersion, filename) +} diff --git a/packages/shared/src/CompilerVersions.res b/packages/shared/src/CompilerVersions.res new file mode 100644 index 000000000..1d612897f --- /dev/null +++ b/packages/shared/src/CompilerVersions.res @@ -0,0 +1,20 @@ +let isSupported = (version: Semver.t) => + switch version.major { + | major if major < 10 => false + | 10 => version.minor >= 1 + | 11 => + // Preserve the playground's minimum stable v11 version. + version.preRelease->Option.isNone && + (version.minor > 1 || (version.minor === 1 && version.patch >= 4)) + | 12 => version.preRelease->Option.isNone || version.minor > 1 + | _ => true + } + +let supported = versions => + versions + ->Array.filterMap(Semver.parse) + ->Array.filter(isSupported) + ->Array.toSorted((a, b) => Semver.compare(b, a)) + +let latestStable = versions => + versions->Array.find(version => version.Semver.preRelease->Option.isNone) diff --git a/packages/shared/src/Node.res b/packages/shared/src/Node.res index cc49181dd..6631af477 100644 --- a/packages/shared/src/Node.res +++ b/packages/shared/src/Node.res @@ -16,11 +16,6 @@ module Process = { @scope("process") external env: Dict.t = "env" @scope("process") @val external argv: array = "argv" @scope("process") external exit: int => unit = "exit" - module Env = { - @scope(("process", "env")) external nodeEnv: string = "NODE_ENV" - @scope(("process", "env")) - external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" - } } module Fs = { diff --git a/packages/shared/src/RuntimeConsole.res b/packages/shared/src/RuntimeConsole.res new file mode 100644 index 000000000..d5853afac --- /dev/null +++ b/packages/shared/src/RuntimeConsole.res @@ -0,0 +1,39 @@ +type level = [#log | #warn | #error] +type log = {level: level, content: array} + +@module("./runtimeConsole.js") external bridgeScript: string = "bridgeScript" + +@val external toText: JSON.t => string = "String" + +let argumentText = value => + switch value { + | JSON.String(_) | JSON.Number(_) | JSON.Boolean(_) => Some(toText(value)) + | _ => None + } + +let fromMessage = data => + switch data { + | JSON.Object(dict) => + let level = switch dict->Dict.get("type") { + | Some(JSON.String("log")) => Some(#log) + | Some(JSON.String("warn")) => Some(#warn) + | Some(JSON.String("error")) => Some(#error) + | _ => None + } + let args = dict->Dict.get("args")->Option.flatMap(JSON.Decode.array) + switch (level, args) { + | (Some(level), Some(args)) => + let content = args->Array.filterMap(argumentText) + content->Array.length === args->Array.length ? Some({level, content}) : None + | _ => None + } + | _ => None + } + +let text = log => log.content->Array.join(" ") + +let useLogs = onLog => React.useEffect(() => { + let listener = event => event["data"]->fromMessage->Option.forEach(onLog) + WebAPI.Window.addEventListener(window, Custom("message"), listener) + Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), listener)) + }, [onLog]) diff --git a/packages/shared/src/Semver.res b/packages/shared/src/Semver.res index c0a6420f5..a8b3e6208 100644 --- a/packages/shared/src/Semver.res +++ b/packages/shared/src/Semver.res @@ -73,3 +73,39 @@ let tryGetMajorString = (versionStr: string) => | None => versionStr // fallback to given version if it cannot be parsed | Some({major}) => "v" ++ major->Int.toString } + +let preReleaseParts = preRelease => + switch preRelease { + | Alpha(number) => ("alpha", number) + | Beta(number) => ("beta", number) + | Dev(number) => ("dev", number) + | Rc(number) => ("rc", number) + } + +let comparePreRelease = (a, b) => + switch (a, b) { + | (None, None) => 0.0 + | (None, Some(_)) => 1.0 + | (Some(_), None) => -1.0 + | (Some(a), Some(b)) => + let (aName, aNumber) = preReleaseParts(a) + let (bName, bNumber) = preReleaseParts(b) + switch String.compare(aName, bName) { + | 0.0 => Int.compare(aNumber, bNumber) + | result => result + } + } + +let compare = (a, b) => + switch Int.compare(a.major, b.major) { + | 0.0 => + switch Int.compare(a.minor, b.minor) { + | 0.0 => + switch Int.compare(a.patch, b.patch) { + | 0.0 => comparePreRelease(a.preRelease, b.preRelease) + | result => result + } + | result => result + } + | result => result + } diff --git a/packages/shared/src/Semver.resi b/packages/shared/src/Semver.resi index f49e4c0b6..c3289785a 100644 --- a/packages/shared/src/Semver.resi +++ b/packages/shared/src/Semver.resi @@ -16,3 +16,5 @@ let parse: string => option let toString: t => string let tryGetMajorString: string => string + +let compare: (t, t) => float diff --git a/packages/shared/src/runtimeConsole.js b/packages/shared/src/runtimeConsole.js new file mode 100644 index 000000000..be097cb8b --- /dev/null +++ b/packages/shared/src/runtimeConsole.js @@ -0,0 +1,25 @@ +export function serializeArg(arg) { + if (arg === undefined) return "undefined"; + if (arg === null) return "null"; + if (typeof arg === "object") + return JSON.stringify(arg, Object.getOwnPropertyNames(arg)); + if (typeof arg === "function") return arg.toString(); + return arg; +} + +function installConsole(serializeArg) { + const sendLog = + (level) => + (...args) => { + parent.window.postMessage( + { type: level, args: args.map(serializeArg) }, + "*", + ); + }; + console.log = sendLog("log"); + console.warn = sendLog("warn"); + console.error = sendLog("error"); +} + +// Both functions are self-contained so the iframe can install them without module loading. +export const bridgeScript = `(${installConsole.toString()})(${serializeArg.toString()});`;