mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-16 00:44:59 +00:00
parallelize script loads
This commit is contained in:
parent
9d3554de02
commit
2f23934de0
17 changed files with 561 additions and 68 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
{{
|
{{
|
||||||
// Collect exec() script paths during parsing (deduplicated)
|
// Collect exec() script paths during parsing (deduplicated)
|
||||||
const execScriptPathsSet = new Set();
|
// These are reset in the per-parse initializer below
|
||||||
let hasDynamicExec = false;
|
let execScriptPathsSet;
|
||||||
|
let hasDynamicExec;
|
||||||
|
|
||||||
function buildBinaryExpression(head, tail) {
|
function buildBinaryExpression(head, tail) {
|
||||||
return tail.reduce((left, [op, right]) => ({
|
return tail.reduce((left, [op, right]) => ({
|
||||||
|
|
@ -41,6 +42,12 @@
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
// Per-parse initializer - reset state for each parse call
|
||||||
|
{
|
||||||
|
execScriptPathsSet = new Set();
|
||||||
|
hasDynamicExec = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Main entry point
|
// Main entry point
|
||||||
Program
|
Program
|
||||||
= ws items:((Comment / Statement) ws)* {
|
= ws items:((Comment / Statement) ws)* {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/app/page-992a02d96730d72d.js
Normal file
1
docs/_next/static/chunks/app/page-992a02d96730d72d.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@
|
||||||
2:I[9766,[],""]
|
2:I[9766,[],""]
|
||||||
3:I[8924,[],""]
|
3:I[8924,[],""]
|
||||||
4:I[1959,[],"ClientPageRoot"]
|
4:I[1959,[],"ClientPageRoot"]
|
||||||
5:I[8283,["367","static/chunks/b536a0f1-05ee2c75df4a3b9d.js","831","static/chunks/bd904a5c-3aea2adebde6f067.js","664","static/chunks/a3cd4a83-5c5b758da206345b.js","794","static/chunks/f6211eb1-4f3105d2434536dc.js","413","static/chunks/1329d575-16915d95397758f8.js","331","static/chunks/331-37e8e553d8a20c21.js","974","static/chunks/app/page-9325599301306e96.js"],"default"]
|
5:I[8283,["367","static/chunks/b536a0f1-05ee2c75df4a3b9d.js","831","static/chunks/bd904a5c-3aea2adebde6f067.js","664","static/chunks/a3cd4a83-5c5b758da206345b.js","794","static/chunks/f6211eb1-4f3105d2434536dc.js","413","static/chunks/1329d575-16915d95397758f8.js","331","static/chunks/331-37e8e553d8a20c21.js","974","static/chunks/app/page-992a02d96730d72d.js"],"default"]
|
||||||
8:I[4431,[],"OutletBoundary"]
|
8:I[4431,[],"OutletBoundary"]
|
||||||
a:I[5278,[],"AsyncMetadataOutlet"]
|
a:I[5278,[],"AsyncMetadataOutlet"]
|
||||||
c:I[4431,[],"ViewportBoundary"]
|
c:I[4431,[],"ViewportBoundary"]
|
||||||
|
|
@ -10,7 +10,7 @@ e:I[4431,[],"MetadataBoundary"]
|
||||||
f:"$Sreact.suspense"
|
f:"$Sreact.suspense"
|
||||||
11:I[7150,[],""]
|
11:I[7150,[],""]
|
||||||
:HL["/t2-mapper/_next/static/css/9e91738631ff0ad7.css","style"]
|
:HL["/t2-mapper/_next/static/css/9e91738631ff0ad7.css","style"]
|
||||||
0:{"P":null,"b":"hNI-Z4JlDIdVyOrrvkkaL","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/css/9e91738631ff0ad7.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],null,["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[]],"s":false,"S":true}
|
0:{"P":null,"b":"tafDAgWrdAOojPa0uDzEv","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/css/9e91738631ff0ad7.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],null,["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[]],"s":false,"S":true}
|
||||||
6:{}
|
6:{}
|
||||||
7:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
7:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
||||||
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@
|
||||||
|
|
||||||
|
|
||||||
// Collect exec() script paths during parsing (deduplicated)
|
// Collect exec() script paths during parsing (deduplicated)
|
||||||
const execScriptPathsSet = new Set();
|
// These are reset in the per-parse initializer below
|
||||||
let hasDynamicExec = false;
|
let execScriptPathsSet;
|
||||||
|
let hasDynamicExec;
|
||||||
|
|
||||||
function buildBinaryExpression(head, tail) {
|
function buildBinaryExpression(head, tail) {
|
||||||
return tail.reduce((left, [op, right]) => ({
|
return tail.reduce((left, [op, right]) => ({
|
||||||
|
|
@ -6407,6 +6408,10 @@ function peg$parse(input, options) {
|
||||||
return s0;
|
return s0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
execScriptPathsSet = new Set();
|
||||||
|
hasDynamicExec = false;
|
||||||
|
|
||||||
peg$result = peg$startRuleFunction();
|
peg$result = peg$startRuleFunction();
|
||||||
|
|
||||||
const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length);
|
const peg$success = (peg$result !== peg$FAILED && peg$currPos === input.length);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Html } from "@react-three/drei";
|
||||||
import picomatch from "picomatch";
|
import picomatch from "picomatch";
|
||||||
import { loadMission } from "../loaders";
|
import { loadMission } from "../loaders";
|
||||||
import { type ParsedMission } from "../mission";
|
import { type ParsedMission } from "../mission";
|
||||||
|
|
@ -12,7 +13,12 @@ import {
|
||||||
runServer,
|
runServer,
|
||||||
TorqueObject,
|
TorqueObject,
|
||||||
} from "../torqueScript";
|
} from "../torqueScript";
|
||||||
import { getResourceKey, getResourceList, getResourceMap } from "../manifest";
|
import {
|
||||||
|
getResourceKey,
|
||||||
|
getResourceList,
|
||||||
|
getResourceMap,
|
||||||
|
getSourceAndPath,
|
||||||
|
} from "../manifest";
|
||||||
|
|
||||||
const loadScript = createScriptLoader();
|
const loadScript = createScriptLoader();
|
||||||
// Shared cache for parsed scripts - survives runtime restarts
|
// Shared cache for parsed scripts - survives runtime restarts
|
||||||
|
|
@ -20,7 +26,12 @@ const scriptCache = createScriptCache();
|
||||||
const fileSystem: FileSystemHandler = {
|
const fileSystem: FileSystemHandler = {
|
||||||
findFiles: (pattern) => {
|
findFiles: (pattern) => {
|
||||||
const isMatch = picomatch(pattern, { nocase: true });
|
const isMatch = picomatch(pattern, { nocase: true });
|
||||||
return getResourceList().filter((path) => isMatch(path));
|
return getResourceList()
|
||||||
|
.filter((path) => isMatch(path))
|
||||||
|
.map((resourceKey) => {
|
||||||
|
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
|
||||||
|
return actualPath;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
isFile: (resourcePath) => {
|
isFile: (resourcePath) => {
|
||||||
const resourceKeys = getResourceMap();
|
const resourceKeys = getResourceMap();
|
||||||
|
|
@ -75,12 +86,34 @@ function useExecutedMission(
|
||||||
return missionGroup;
|
return missionGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
border: "4px solid rgba(255, 255, 255, 0.2)",
|
||||||
|
borderTopColor: "white",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<style>{`@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } from { transform: translate(-50%, -50%) rotate(0deg); } }`}</style>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Mission = memo(function Mission({ name }: { name: string }) {
|
export const Mission = memo(function Mission({ name }: { name: string }) {
|
||||||
const { data: parsedMission } = useParsedMission(name);
|
const { data: parsedMission } = useParsedMission(name);
|
||||||
const missionGroup = useExecutedMission(name, parsedMission);
|
const missionGroup = useExecutedMission(name, parsedMission);
|
||||||
|
|
||||||
if (!missionGroup) {
|
if (!missionGroup) {
|
||||||
return null;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TickProvider>{renderObject(missionGroup)}</TickProvider>;
|
return <TickProvider>{renderObject(missionGroup)}</TickProvider>;
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,15 @@ export function createBuiltins(
|
||||||
console.debug(
|
console.debug(
|
||||||
`exec(${JSON.stringify(pathString)}): preparing to execute…`,
|
`exec(${JSON.stringify(pathString)}): preparing to execute…`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Engine requires an extension - check for a '.' in the filename
|
||||||
|
if (!pathString.includes(".")) {
|
||||||
|
console.error(
|
||||||
|
`exec: invalid script file name ${JSON.stringify(pathString)}.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedPath = normalizePath(pathString);
|
const normalizedPath = normalizePath(pathString);
|
||||||
const rt = runtime();
|
const rt = runtime();
|
||||||
const { executedScripts, scripts } = rt.state;
|
const { executedScripts, scripts } = rt.state;
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export interface RunServerResult {
|
||||||
export function runServer(options: RunServerOptions): RunServerResult {
|
export function runServer(options: RunServerOptions): RunServerResult {
|
||||||
const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
|
const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
|
||||||
options;
|
options;
|
||||||
const { signal } = runtimeOptions ?? {};
|
const { signal, fileSystem } = runtimeOptions ?? {};
|
||||||
|
|
||||||
const runtime = createRuntime({
|
const runtime = createRuntime({
|
||||||
...runtimeOptions,
|
...runtimeOptions,
|
||||||
|
|
@ -87,16 +87,36 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
||||||
const gameTypeName = `${missionType}Game`;
|
const gameTypeName = `${missionType}Game`;
|
||||||
const gameTypeScript = `scripts/${gameTypeName}.cs`;
|
const gameTypeScript = `scripts/${gameTypeName}.cs`;
|
||||||
|
|
||||||
const ready = (async () => {
|
const ready = (async function createServer() {
|
||||||
try {
|
try {
|
||||||
// Load all required scripts
|
// Load all required scripts
|
||||||
const serverScript = await runtime.loadFromPath("scripts/server.cs");
|
const serverScript = await runtime.loadFromPath("scripts/server.cs");
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
// These are dynamic exec() calls in server.cs since their paths are
|
|
||||||
// computed based on the game type and mission. So, we need to load them
|
// server.cs has a glob loop that does: findFirstFile("scripts/*Game.cs")
|
||||||
// ahead of time so they're available to execute.
|
// and then exec()s each result dynamically. Since we can't statically
|
||||||
await runtime.loadFromPath(gameTypeScript);
|
// analyze dynamic exec paths, we need to either preload all game scripts
|
||||||
|
// in the same way (so they're available when exec() is called) or just
|
||||||
|
// exec() the ones we know we need...
|
||||||
|
//
|
||||||
|
// To load them all, do:
|
||||||
|
// if (fileSystem) {
|
||||||
|
// const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
|
||||||
|
// await Promise.all(
|
||||||
|
// gameScripts.map((path) => runtime.loadFromPath(path)),
|
||||||
|
// );
|
||||||
|
// signal?.throwIfAborted();
|
||||||
|
// }
|
||||||
|
await runtime.loadFromPath("scripts/DefaultGame.cs");
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
try {
|
||||||
|
await runtime.loadFromPath(gameTypeScript);
|
||||||
|
} catch (err) {
|
||||||
|
// It's OK if that one fails. Not every game type needs its own script.
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
|
// Also preload the mission file (another dynamic exec path)
|
||||||
await runtime.loadFromPath(`missions/${missionName}.mis`);
|
await runtime.loadFromPath(`missions/${missionName}.mis`);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
|
|
@ -112,7 +132,7 @@ export function runServer(options: RunServerOptions): RunServerResult {
|
||||||
// which we added specifically to solve this problem.
|
// which we added specifically to solve this problem.
|
||||||
if (onMissionLoadDone) {
|
if (onMissionLoadDone) {
|
||||||
runtime.$.onMethodCalled(
|
runtime.$.onMethodCalled(
|
||||||
gameTypeName,
|
"DefaultGame",
|
||||||
"missionLoadDone",
|
"missionLoadDone",
|
||||||
onMissionLoadDone,
|
onMissionLoadDone,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1353,7 +1353,9 @@ describe("TorqueScript Runtime", () => {
|
||||||
function createLoader(
|
function createLoader(
|
||||||
files: Record<string, string>,
|
files: Record<string, string>,
|
||||||
): (path: string) => Promise<string | null> {
|
): (path: string) => Promise<string | null> {
|
||||||
return async (path: string) => files[path.toLowerCase()] ?? null;
|
// Loader normalizes paths itself (slashes + lowercase)
|
||||||
|
return async (path: string) =>
|
||||||
|
files[path.replace(/\\/g, "/").toLowerCase()] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("executes scripts via exec()", async () => {
|
it("executes scripts via exec()", async () => {
|
||||||
|
|
@ -1419,6 +1421,202 @@ describe("TorqueScript Runtime", () => {
|
||||||
expect(runtime.$g.get("Loaded")).toBe(true);
|
expect(runtime.$g.get("Loaded")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("path normalization for state tracking", () => {
|
||||||
|
it("treats different cases as the same file for loaded scripts", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadCount++;
|
||||||
|
if (path.toLowerCase() === "scripts/test.cs") {
|
||||||
|
return "$LoadCount = $LoadCount + 1;";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load with different cases - should only call loader once
|
||||||
|
await runtime.loadFromPath("scripts/test.cs");
|
||||||
|
await runtime.loadFromPath("Scripts/Test.cs");
|
||||||
|
await runtime.loadFromPath("SCRIPTS/TEST.CS");
|
||||||
|
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats different slashes as the same file for loaded scripts", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadCount++;
|
||||||
|
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
|
||||||
|
return "$LoadCount = $LoadCount + 1;";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load with different slash styles - should only call loader once
|
||||||
|
await runtime.loadFromPath("scripts/test.cs");
|
||||||
|
await runtime.loadFromPath("scripts\\test.cs");
|
||||||
|
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats different cases as the same file for failed scripts", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async () => {
|
||||||
|
loadCount++;
|
||||||
|
return null; // Script not found
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Try to load with different cases - should only call loader once
|
||||||
|
const script = await runtime.loadFromSource(`
|
||||||
|
exec("scripts/missing.cs");
|
||||||
|
exec("Scripts/Missing.cs");
|
||||||
|
exec("SCRIPTS/MISSING.CS");
|
||||||
|
`);
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
|
||||||
|
// Loader should only be called once since subsequent attempts
|
||||||
|
// see it's already in failedScripts
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats different slashes as the same file for failed scripts", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async () => {
|
||||||
|
loadCount++;
|
||||||
|
return null; // Script not found
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Try to load with different slash styles
|
||||||
|
const script = await runtime.loadFromSource(`
|
||||||
|
exec("scripts/missing.cs");
|
||||||
|
exec("scripts\\\\missing.cs");
|
||||||
|
`);
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats different cases as the same file for executed scripts", async () => {
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: createLoader({
|
||||||
|
"scripts/counter.cs": "$Counter = $Counter + 1;",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.$g.set("Counter", 0);
|
||||||
|
|
||||||
|
const script = await runtime.loadFromSource(`
|
||||||
|
exec("scripts/counter.cs");
|
||||||
|
exec("Scripts/Counter.cs");
|
||||||
|
exec("SCRIPTS/COUNTER.CS");
|
||||||
|
`);
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
// Script should only execute once despite different cases
|
||||||
|
expect(runtime.$g.get("Counter")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats different slashes as the same file for executed scripts", async () => {
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: createLoader({
|
||||||
|
"scripts/counter.cs": "$Counter = $Counter + 1;",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.$g.set("Counter", 0);
|
||||||
|
|
||||||
|
const script = await runtime.loadFromSource(`
|
||||||
|
exec("scripts/counter.cs");
|
||||||
|
exec("scripts\\\\counter.cs");
|
||||||
|
`);
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
// Script should only execute once despite different slashes
|
||||||
|
expect(runtime.$g.get("Counter")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches sequential loads with different cases", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadCount++;
|
||||||
|
if (path.toLowerCase() === "scripts/test.cs") {
|
||||||
|
return "$Loaded = true;";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load same file with different cases sequentially
|
||||||
|
await runtime.loadFromPath("scripts/test.cs");
|
||||||
|
await runtime.loadFromPath("Scripts/Test.cs");
|
||||||
|
await runtime.loadFromPath("SCRIPTS/TEST.CS");
|
||||||
|
|
||||||
|
// Should only load once since cached after first load
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches sequential loads with different slashes", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadCount++;
|
||||||
|
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
|
||||||
|
return "$Loaded = true;";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load same file with different slashes sequentially
|
||||||
|
await runtime.loadFromPath("scripts/test.cs");
|
||||||
|
await runtime.loadFromPath("scripts\\test.cs");
|
||||||
|
|
||||||
|
// Should only load once since cached after first load
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles combined case and slash differences", async () => {
|
||||||
|
let loadCount = 0;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadCount++;
|
||||||
|
if (path.replace(/\\/g, "/").toLowerCase() === "scripts/test.cs") {
|
||||||
|
return "$Counter = $Counter + 1;";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.$g.set("Counter", 0);
|
||||||
|
|
||||||
|
const script = await runtime.loadFromSource(`
|
||||||
|
exec("scripts/test.cs");
|
||||||
|
exec("Scripts\\\\Test.cs");
|
||||||
|
exec("SCRIPTS/TEST.CS");
|
||||||
|
exec("scripts\\\\TEST.CS");
|
||||||
|
`);
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
// All variations should be treated as the same file
|
||||||
|
expect(loadCount).toBe(1);
|
||||||
|
expect(runtime.$g.get("Counter")).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns false when script is not found", async () => {
|
it("returns false when script is not found", async () => {
|
||||||
const runtime = createRuntime({
|
const runtime = createRuntime({
|
||||||
loadScript: async () => null,
|
loadScript: async () => null,
|
||||||
|
|
@ -1462,6 +1660,24 @@ describe("TorqueScript Runtime", () => {
|
||||||
expect(runtime.$g.get("Result")).toBe(false);
|
expect(runtime.$g.get("Result")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns false and logs error when exec called without extension", async () => {
|
||||||
|
// Engine requires file extension (matches tribes2-engine/TorqueSDK-1.2 behavior)
|
||||||
|
const runtime = createRuntime();
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
const script = await runtime.loadFromSource(
|
||||||
|
'$Result = exec("scripts/noextension");',
|
||||||
|
);
|
||||||
|
script.execute();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
'exec: invalid script file name "scripts/noextension".',
|
||||||
|
);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
debugSpy.mockRestore();
|
||||||
|
expect(runtime.$g.get("Result")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("shares globals between exec'd scripts", async () => {
|
it("shares globals between exec'd scripts", async () => {
|
||||||
const runtime = createRuntime({
|
const runtime = createRuntime({
|
||||||
loadScript: createLoader({
|
loadScript: createLoader({
|
||||||
|
|
@ -1659,6 +1875,177 @@ describe("TorqueScript Runtime", () => {
|
||||||
|
|
||||||
expect(loadCount).toBe(1); // Only loaded once
|
expect(loadCount).toBe(1); // Only loaded once
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles diamond dependencies (parallel deduplication)", async () => {
|
||||||
|
// A depends on B and C, both B and C depend on D
|
||||||
|
// D should only be loaded once
|
||||||
|
let dLoadCount = 0;
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
|
||||||
|
"scripts/b.cs": 'exec("scripts/d.cs"); $B = 1;',
|
||||||
|
"scripts/c.cs": 'exec("scripts/d.cs"); $C = 1;',
|
||||||
|
"scripts/d.cs": "$D = 1;",
|
||||||
|
};
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
if (path === "scripts/d.cs") dLoadCount++;
|
||||||
|
return sources[path] ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = await runtime.loadFromPath("scripts/a.cs");
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
expect(dLoadCount).toBe(1); // D loaded only once despite being dep of both B and C
|
||||||
|
expect(runtime.$g.get("A")).toBe(1);
|
||||||
|
expect(runtime.$g.get("B")).toBe(1);
|
||||||
|
expect(runtime.$g.get("C")).toBe(1);
|
||||||
|
expect(runtime.$g.get("D")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles longer cycles not involving main script (A → B → C → B)", async () => {
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/a.cs": 'exec("scripts/b.cs"); $A = 1;',
|
||||||
|
"scripts/b.cs": 'exec("scripts/c.cs"); $B = 1;',
|
||||||
|
"scripts/c.cs": 'exec("scripts/b.cs"); $C = 1;', // Cycle back to B
|
||||||
|
};
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => sources[path] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not hang
|
||||||
|
const script = await runtime.loadFromPath("scripts/a.cs");
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
expect(runtime.$g.get("A")).toBe(1);
|
||||||
|
expect(runtime.$g.get("B")).toBe(1);
|
||||||
|
expect(runtime.$g.get("C")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple scripts depending on each other in complex patterns", async () => {
|
||||||
|
// A → [B, C], B → [D, E], C → [E, F], D → G, E → G, F → G
|
||||||
|
// G should only be loaded once
|
||||||
|
let gLoadCount = 0;
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/a.cs": 'exec("scripts/b.cs"); exec("scripts/c.cs"); $A = 1;',
|
||||||
|
"scripts/b.cs": 'exec("scripts/d.cs"); exec("scripts/e.cs"); $B = 1;',
|
||||||
|
"scripts/c.cs": 'exec("scripts/e.cs"); exec("scripts/f.cs"); $C = 1;',
|
||||||
|
"scripts/d.cs": 'exec("scripts/g.cs"); $D = 1;',
|
||||||
|
"scripts/e.cs": 'exec("scripts/g.cs"); $E = 1;',
|
||||||
|
"scripts/f.cs": 'exec("scripts/g.cs"); $F = 1;',
|
||||||
|
"scripts/g.cs": "$G = 1;",
|
||||||
|
};
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
if (path === "scripts/g.cs") gLoadCount++;
|
||||||
|
return sources[path] ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const script = await runtime.loadFromPath("scripts/a.cs");
|
||||||
|
script.execute();
|
||||||
|
|
||||||
|
expect(gLoadCount).toBe(1); // G loaded only once
|
||||||
|
expect(runtime.$g.get("A")).toBe(1);
|
||||||
|
expect(runtime.$g.get("G")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues loading other scripts when one fails", async () => {
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/main.cs":
|
||||||
|
'exec("scripts/good.cs"); exec("scripts/bad.cs"); exec("scripts/also-good.cs"); $Main = 1;',
|
||||||
|
"scripts/good.cs": "$Good = 1;",
|
||||||
|
// bad.cs is missing
|
||||||
|
"scripts/also-good.cs": "$AlsoGood = 1;",
|
||||||
|
};
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => sources[path] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const script = await runtime.loadFromPath("scripts/main.cs");
|
||||||
|
script.execute();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
|
||||||
|
// Good scripts should still be loaded and executed
|
||||||
|
expect(runtime.$g.get("Main")).toBe(1);
|
||||||
|
expect(runtime.$g.get("Good")).toBe(1);
|
||||||
|
expect(runtime.$g.get("AlsoGood")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles parse errors gracefully without blocking other scripts", async () => {
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/main.cs":
|
||||||
|
'exec("scripts/good.cs"); exec("scripts/bad-syntax.cs"); $Main = 1;',
|
||||||
|
"scripts/good.cs": "$Good = 1;",
|
||||||
|
"scripts/bad-syntax.cs": "this is not valid { syntax",
|
||||||
|
};
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => sources[path] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const script = await runtime.loadFromPath("scripts/main.cs");
|
||||||
|
script.execute();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
|
||||||
|
expect(runtime.$g.get("Main")).toBe(1);
|
||||||
|
expect(runtime.$g.get("Good")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects pre-aborted signal when loading dependencies", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort(); // Abort before starting
|
||||||
|
|
||||||
|
let loaderCalled = false;
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async () => {
|
||||||
|
loaderCalled = true;
|
||||||
|
return "$X = 1;";
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load a script that has dependencies - should throw when trying to load deps
|
||||||
|
await expect(
|
||||||
|
runtime.loadFromSource('exec("scripts/dep.cs"); $Main = 1;'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(loaderCalled).toBe(false); // Loader should never be called
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads scripts in parallel (not serially)", async () => {
|
||||||
|
const loadOrder: string[] = [];
|
||||||
|
const sources: Record<string, string> = {
|
||||||
|
"scripts/main.cs":
|
||||||
|
'exec("scripts/a.cs"); exec("scripts/b.cs"); exec("scripts/c.cs");',
|
||||||
|
"scripts/a.cs": "$A = 1;",
|
||||||
|
"scripts/b.cs": "$B = 1;",
|
||||||
|
"scripts/c.cs": "$C = 1;",
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = createRuntime({
|
||||||
|
loadScript: async (path) => {
|
||||||
|
loadOrder.push(`start:${path}`);
|
||||||
|
// Small delay to allow interleaving
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
loadOrder.push(`end:${path}`);
|
||||||
|
return sources[path] ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.loadFromPath("scripts/main.cs");
|
||||||
|
|
||||||
|
// If parallel: starts happen before all ends
|
||||||
|
// If serial: each end would happen before the next start
|
||||||
|
const aStart = loadOrder.indexOf("start:scripts/a.cs");
|
||||||
|
const bStart = loadOrder.indexOf("start:scripts/b.cs");
|
||||||
|
const cStart = loadOrder.indexOf("start:scripts/c.cs");
|
||||||
|
const aEnd = loadOrder.indexOf("end:scripts/a.cs");
|
||||||
|
|
||||||
|
// All starts should happen before any of them ends (parallel behavior)
|
||||||
|
expect(bStart).toBeLessThan(aEnd);
|
||||||
|
expect(cStart).toBeLessThan(aEnd);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("path option", () => {
|
describe("path option", () => {
|
||||||
|
|
|
||||||
|
|
@ -1066,67 +1066,85 @@ export function createRuntime(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDependencies(
|
async function loadDependencies(
|
||||||
ast: Program,
|
scriptsToLoad: string[],
|
||||||
loading: Set<string>,
|
loadingPromises: Map<string, Promise<void>>,
|
||||||
includePreload: boolean = false,
|
ancestors: Set<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const loader = options.loadScript;
|
const loader = options.loadScript;
|
||||||
if (!loader) {
|
if (!loader) {
|
||||||
// No loader, can't resolve dependencies
|
if (scriptsToLoad.length > 0) {
|
||||||
if (ast.execScriptPaths.length > 0) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`Script has exec() calls but no loadScript provided:`,
|
`Script has exec() calls but no loadScript provided:`,
|
||||||
ast.execScriptPaths,
|
scriptsToLoad,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine static exec() paths with preload scripts (on first call only)
|
async function loadSingleScript(ref: string): Promise<void> {
|
||||||
const scriptsToLoad = includePreload
|
|
||||||
? [...ast.execScriptPaths, ...(options.preloadScripts ?? [])]
|
|
||||||
: ast.execScriptPaths;
|
|
||||||
|
|
||||||
for (const ref of scriptsToLoad) {
|
|
||||||
options.signal?.throwIfAborted();
|
options.signal?.throwIfAborted();
|
||||||
const normalized = normalizePath(ref);
|
const normalized = normalizePath(ref);
|
||||||
|
|
||||||
// Skip if already loaded, failed, or currently loading (cycle detection)
|
// Skip if already loaded or failed
|
||||||
if (
|
if (
|
||||||
state.scripts.has(normalized) ||
|
state.scripts.has(normalized) ||
|
||||||
state.failedScripts.has(normalized) ||
|
state.failedScripts.has(normalized)
|
||||||
loading.has(normalized)
|
|
||||||
) {
|
) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.add(normalized);
|
// If this script is an ancestor in our load chain, it's a cycle - skip
|
||||||
|
// (awaiting would cause deadlock)
|
||||||
const source = await loader(ref);
|
if (ancestors.has(normalized)) {
|
||||||
if (source == null) {
|
return;
|
||||||
console.warn(`Script not found: ${ref}`);
|
|
||||||
state.failedScripts.add(normalized);
|
|
||||||
loading.delete(normalized);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let depAst: Program;
|
// If already loading from a parallel branch, wait for it to complete
|
||||||
try {
|
const existingPromise = loadingPromises.get(normalized);
|
||||||
depAst = parse(source, { filename: ref });
|
if (existingPromise) {
|
||||||
} catch (err) {
|
await existingPromise;
|
||||||
console.warn(`Failed to parse script: ${ref}`, err);
|
return;
|
||||||
state.failedScripts.add(normalized);
|
|
||||||
loading.delete(normalized);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively load this script's dependencies first
|
const loadPromise = (async () => {
|
||||||
await loadDependencies(depAst, loading);
|
// Pass original path to loader - it handles its own normalization
|
||||||
|
const source = await loader(ref);
|
||||||
|
if (source == null) {
|
||||||
|
console.warn(`Script not found: ${ref}`);
|
||||||
|
state.failedScripts.add(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store the parsed AST
|
let depAst: Program;
|
||||||
state.scripts.set(normalized, depAst);
|
try {
|
||||||
loading.delete(normalized);
|
depAst = parse(source, { filename: ref });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to parse script: ${ref}`, err);
|
||||||
|
state.failedScripts.add(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this script to ancestors for nested loads (cycle detection)
|
||||||
|
const newAncestors = new Set(ancestors);
|
||||||
|
newAncestors.add(normalized);
|
||||||
|
|
||||||
|
// Recursively load this script's dependencies in parallel
|
||||||
|
await loadDependencies(
|
||||||
|
depAst.execScriptPaths,
|
||||||
|
loadingPromises,
|
||||||
|
newAncestors,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the parsed AST
|
||||||
|
state.scripts.set(normalized, depAst);
|
||||||
|
})();
|
||||||
|
|
||||||
|
loadingPromises.set(normalized, loadPromise);
|
||||||
|
await loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load all scripts in parallel
|
||||||
|
await Promise.all(scriptsToLoad.map(loadSingleScript));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromPath(path: string): Promise<LoadedScript> {
|
async function loadFromPath(path: string): Promise<LoadedScript> {
|
||||||
|
|
@ -1135,12 +1153,12 @@ export function createRuntime(
|
||||||
throw new Error("loadFromPath requires loadScript option to be set");
|
throw new Error("loadFromPath requires loadScript option to be set");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already loaded (avoid unnecessary fetch)
|
|
||||||
const normalized = normalizePath(path);
|
const normalized = normalizePath(path);
|
||||||
if (state.scripts.has(normalized)) {
|
if (state.scripts.has(normalized)) {
|
||||||
return createLoadedScript(state.scripts.get(normalized)!, path);
|
return createLoadedScript(state.scripts.get(normalized)!, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass original path to loader - it handles its own normalization
|
||||||
const source = await loader(path);
|
const source = await loader(path);
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
throw new Error(`Script not found: ${path}`);
|
throw new Error(`Script not found: ${path}`);
|
||||||
|
|
@ -1172,14 +1190,21 @@ export function createRuntime(
|
||||||
ast: Program,
|
ast: Program,
|
||||||
loadOptions?: LoadScriptOptions,
|
loadOptions?: LoadScriptOptions,
|
||||||
): Promise<LoadedScript> {
|
): Promise<LoadedScript> {
|
||||||
// Load dependencies (include preload scripts on initial load)
|
const loadingPromises = new Map<string, Promise<void>>();
|
||||||
const loading = new Set<string>();
|
const ancestors = new Set<string>();
|
||||||
if (loadOptions?.path) {
|
if (loadOptions?.path) {
|
||||||
const normalized = normalizePath(loadOptions.path);
|
const normalized = normalizePath(loadOptions.path);
|
||||||
loading.add(normalized);
|
// Mark the main script as loaded to prevent cycles back to it
|
||||||
state.scripts.set(normalized, ast);
|
state.scripts.set(normalized, ast);
|
||||||
|
ancestors.add(normalized);
|
||||||
}
|
}
|
||||||
await loadDependencies(ast, loading, true);
|
|
||||||
|
// Load dependencies and any preload scripts
|
||||||
|
const scriptsToLoad = [
|
||||||
|
...ast.execScriptPaths,
|
||||||
|
...(options.preloadScripts ?? []),
|
||||||
|
];
|
||||||
|
await loadDependencies(scriptsToLoad, loadingPromises, ancestors);
|
||||||
|
|
||||||
return createLoadedScript(ast, loadOptions?.path);
|
return createLoadedScript(ast, loadOptions?.path);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,20 @@ export function createScriptLoader(): ScriptLoader {
|
||||||
try {
|
try {
|
||||||
url = getUrlForPath(path);
|
url = getUrlForPath(path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Script not in manifest: ${path}`, err);
|
console.warn(`Script not in manifest: ${path} (${err})`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn(`Script fetch failed: ${path} (${response.status})`);
|
console.error(`Script fetch failed: ${path} (${response.status})`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await response.text();
|
return await response.text();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Script fetch error: ${path}`, err);
|
console.error(`Script fetch error: ${path}`);
|
||||||
|
console.error(err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,12 @@ export class CaseInsensitiveSet {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePath(path: string): string {
|
/** Normalize path separators only (backslashes to forward slashes). */
|
||||||
return path.replace(/\\/g, "/").toLowerCase();
|
export function normalizeSlashes(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize path for use as a cache key (slashes + lowercase). */
|
||||||
|
export function normalizePath(path: string): string {
|
||||||
|
return normalizeSlashes(path).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue