@askrjs/askr 0.0.95 → 0.0.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/benchmark.js CHANGED
@@ -63,8 +63,8 @@ function BenchmarkTable({ rows, isSelected, onSelect, onRemove }) {
63
63
  installRendererBridge();
64
64
  const benchmarkMetadata = {
65
65
  packageName: "@askrjs/askr",
66
- packageVersion: "0.0.95",
67
- buildLabel: "0.0.95-local"
66
+ packageVersion: "0.0.96",
67
+ buildLabel: "0.0.96-local"
68
68
  };
69
69
  function getBenchmarkMetadata() {
70
70
  return { ...benchmarkMetadata };
package/dist/ssg/index.js CHANGED
@@ -10,6 +10,23 @@ import * as pathModule from "node:path";
10
10
  import * as os from "node:os";
11
11
  import * as fsSync from "node:fs";
12
12
  //#region src/ssg/route-utils.ts
13
+ function getPortableOutputPathKey(filePath) {
14
+ return filePath.normalize("NFC").replaceAll("\\", "/").toLowerCase();
15
+ }
16
+ function describeRoute(descriptor) {
17
+ if (descriptor.route.path === descriptor.path) return `"${descriptor.path}"`;
18
+ return `"${descriptor.route.path}" (resolved as "${descriptor.path}")`;
19
+ }
20
+ /** Reject routes that could overwrite one another on any supported filesystem. */
21
+ function validateOutputPathCollisions(descriptors) {
22
+ const seen = /* @__PURE__ */ new Map();
23
+ for (const descriptor of descriptors) {
24
+ const key = getPortableOutputPathKey(descriptor.filePath);
25
+ const existing = seen.get(key);
26
+ if (existing) throw new Error(`SSG output path collision: routes ${describeRoute(existing)} and ${describeRoute(descriptor)} both map to "${descriptor.filePath}" after case-insensitive normalization`);
27
+ seen.set(key, descriptor);
28
+ }
29
+ }
13
30
  function assertSafeRoutePath(pathStr) {
14
31
  if (!pathStr.startsWith("/") || pathStr.startsWith("//") || pathStr.includes("\\") || pathStr.includes("\0") || pathStr.split("/").some((segment) => segment === "." || segment === "..")) throw new Error(`SSG route path must be an absolute URL path without dot segments or backslashes: ${pathStr}`);
15
32
  }
@@ -663,6 +680,24 @@ function resolveParallelism(requested) {
663
680
  const availableParallelism = os.availableParallelism;
664
681
  return Math.max(1, availableParallelism?.() ?? os.cpus().length ?? 1);
665
682
  }
683
+ const outputDirectoryQueues = /* @__PURE__ */ new Map();
684
+ async function withOutputDirectoryLock(outputDir, operation) {
685
+ const key = pathModule.resolve(outputDir).normalize("NFC").toLowerCase();
686
+ const previous = outputDirectoryQueues.get(key) ?? Promise.resolve();
687
+ let release;
688
+ const current = new Promise((resolve) => {
689
+ release = resolve;
690
+ });
691
+ const tail = previous.then(() => current, () => current);
692
+ outputDirectoryQueues.set(key, tail);
693
+ await previous.catch(() => void 0);
694
+ try {
695
+ return await operation();
696
+ } finally {
697
+ release();
698
+ if (outputDirectoryQueues.get(key) === tail) outputDirectoryQueues.delete(key);
699
+ }
700
+ }
666
701
  async function createStagingDirectory(outputDir) {
667
702
  const parent = pathModule.dirname(outputDir);
668
703
  const base = pathModule.basename(outputDir);
@@ -753,136 +788,142 @@ function createStaticGen(options) {
753
788
  * Returns detailed results
754
789
  */
755
790
  async generate(generateOptions = {}) {
756
- const splitRoutes = await splitStaticRoutes(configuredRoutes);
757
- const routes = splitRoutes.routes;
758
- validateRoutes(routes);
759
- const changedKeys = dedupeStrings(generateOptions.changedKeys);
760
- const changedRoutes = dedupeStrings(generateOptions.changedRoutes);
761
- const requestedMode = generateOptions.mode ?? "full";
762
- const previousManifest = !generateOptions.forceFull && requestedMode === "incremental" ? readIncrementalManifest(options.outputDir, seed) : null;
763
- const effectiveMode = generateOptions.forceFull || requestedMode !== "incremental" || previousManifest === null ? "full" : "incremental";
764
- const dataMap = resolveSsgData(routes, { dataOverrides: options.dataOverrides });
765
- const descriptors = routes.map(resolveRouteDescriptor);
766
- const routeResultsById = /* @__PURE__ */ new Map();
767
- for (const runtimeRoute of splitRoutes.runtimeOnly) routeResultsById.set(runtimeRoute.routeId, runtimeRoute.result);
768
- const eligibleDescriptors = descriptors;
769
- const previousEntries = new Map((previousManifest?.routes ?? []).map((entry) => [entry.routeId, entry]));
770
- const currentRouteIds = new Set(eligibleDescriptors.map((descriptor) => descriptor.routeId));
771
- const selected = [];
772
- const routesToRender = [];
773
- for (let index = 0; index < eligibleDescriptors.length; index += 1) {
774
- const descriptor = eligibleDescriptors[index];
775
- const entry = selectRouteForGeneration(descriptor, previousEntries.get(descriptor.routeId) ?? null, effectiveMode, changedKeys, changedRoutes);
776
- selected.push(entry);
777
- if (entry.reason !== "unchanged") routesToRender.push(entry);
778
- }
779
- const renderStartTime = performance.now();
780
- const renderedResults = routesToRender.length > 0 ? await batchRenderRoutes(getRoutesToRender(routesToRender), {
781
- seed,
782
- dataMap,
783
- concurrency: resolvedConcurrency,
784
- document: options.document,
785
- styleRegistrationValidation: options.styleRegistrationValidation
786
- }) : [];
787
- performance.now() - renderStartTime;
788
- const renderedByRouteId = /* @__PURE__ */ new Map();
789
- for (let index = 0; index < renderedResults.length; index += 1) renderedByRouteId.set(routesToRender[index].descriptor.routeId, renderedResults[index]);
790
- let cacheHits = 0;
791
- const nextManifestRoutes = [];
792
- for (const entry of selected) {
793
- const { descriptor, previous, reason } = entry;
794
- const resolvedData = resolveSsgRouteData(dataMap, descriptor.route.path, descriptor.path);
795
- const baseData = resolvedData.hasData ? resolvedData.data : void 0;
796
- const resourceCount = resolvedData.hasData && baseData ? Object.keys(baseData).length : 0;
797
- if (reason === "unchanged") {
798
- routeResultsById.set(descriptor.routeId, {
791
+ return withOutputDirectoryLock(options.outputDir, async () => {
792
+ const splitRoutes = await splitStaticRoutes(configuredRoutes);
793
+ const routes = splitRoutes.routes;
794
+ validateRoutes(routes);
795
+ const changedKeys = dedupeStrings(generateOptions.changedKeys);
796
+ const changedRoutes = dedupeStrings(generateOptions.changedRoutes);
797
+ const requestedMode = generateOptions.mode ?? "full";
798
+ const previousManifest = !generateOptions.forceFull && requestedMode === "incremental" ? readIncrementalManifest(options.outputDir, seed) : null;
799
+ const effectiveMode = generateOptions.forceFull || requestedMode !== "incremental" || previousManifest === null ? "full" : "incremental";
800
+ const dataMap = resolveSsgData(routes, { dataOverrides: options.dataOverrides });
801
+ const descriptors = routes.map(resolveRouteDescriptor);
802
+ validateOutputPathCollisions(descriptors);
803
+ const routeResultsById = /* @__PURE__ */ new Map();
804
+ for (const runtimeRoute of splitRoutes.runtimeOnly) routeResultsById.set(runtimeRoute.routeId, runtimeRoute.result);
805
+ const eligibleDescriptors = descriptors;
806
+ const previousEntries = new Map((previousManifest?.routes ?? []).map((entry) => [entry.routeId, entry]));
807
+ const currentRouteIds = new Set(eligibleDescriptors.map((descriptor) => descriptor.routeId));
808
+ const selected = [];
809
+ const routesToRender = [];
810
+ for (let index = 0; index < eligibleDescriptors.length; index += 1) {
811
+ const descriptor = eligibleDescriptors[index];
812
+ const entry = selectRouteForGeneration(descriptor, previousEntries.get(descriptor.routeId) ?? null, effectiveMode, changedKeys, changedRoutes);
813
+ selected.push(entry);
814
+ if (entry.reason !== "unchanged") routesToRender.push(entry);
815
+ }
816
+ const renderStartTime = performance.now();
817
+ const renderedResults = routesToRender.length > 0 ? await batchRenderRoutes(getRoutesToRender(routesToRender), {
818
+ seed,
819
+ dataMap,
820
+ concurrency: resolvedConcurrency,
821
+ document: options.document,
822
+ styleRegistrationValidation: options.styleRegistrationValidation
823
+ }) : [];
824
+ performance.now() - renderStartTime;
825
+ const renderedByRouteId = /* @__PURE__ */ new Map();
826
+ for (let index = 0; index < renderedResults.length; index += 1) renderedByRouteId.set(routesToRender[index].descriptor.routeId, renderedResults[index]);
827
+ let cacheHits = 0;
828
+ const nextManifestRoutes = [];
829
+ for (const entry of selected) {
830
+ const { descriptor, previous, reason } = entry;
831
+ const resolvedData = resolveSsgRouteData(dataMap, descriptor.route.path, descriptor.path);
832
+ const baseData = resolvedData.hasData ? resolvedData.data : void 0;
833
+ const resourceCount = resolvedData.hasData && baseData ? Object.keys(baseData).length : 0;
834
+ if (reason === "unchanged") {
835
+ routeResultsById.set(descriptor.routeId, {
836
+ path: descriptor.path,
837
+ filePath: descriptor.filePath,
838
+ html: "",
839
+ fileSize: previous?.htmlHash !== null && previous !== null ? getExistingOutputFileSize(options.outputDir, descriptor.filePath) : 0,
840
+ renderDuration: 0,
841
+ resourceCount,
842
+ status: "skipped",
843
+ reason: "unchanged",
844
+ written: false
845
+ });
846
+ if (previous) nextManifestRoutes.push({
847
+ ...previous,
848
+ path: descriptor.path,
849
+ filePath: descriptor.filePath,
850
+ invalidationKeys: descriptor.invalidationKeys.slice()
851
+ });
852
+ continue;
853
+ }
854
+ const rendered = renderedByRouteId.get(descriptor.routeId);
855
+ if (!rendered) throw new Error(`Missing rendered result for route "${descriptor.path}"`);
856
+ const nextResult = {
857
+ ...rendered,
799
858
  path: descriptor.path,
800
859
  filePath: descriptor.filePath,
801
- html: "",
802
- fileSize: previous?.htmlHash !== null && previous !== null ? getExistingOutputFileSize(options.outputDir, descriptor.filePath) : 0,
803
- renderDuration: 0,
804
860
  resourceCount,
805
- status: "skipped",
806
- reason: "unchanged",
861
+ reason,
807
862
  written: false
808
- });
809
- if (previous) nextManifestRoutes.push({
810
- ...previous,
811
- path: descriptor.path,
812
- filePath: descriptor.filePath,
813
- invalidationKeys: descriptor.invalidationKeys.slice()
814
- });
815
- continue;
816
- }
817
- const rendered = renderedByRouteId.get(descriptor.routeId);
818
- if (!rendered) throw new Error(`Missing rendered result for route "${descriptor.path}"`);
819
- const nextResult = {
820
- ...rendered,
821
- path: descriptor.path,
822
- filePath: descriptor.filePath,
823
- resourceCount,
824
- reason,
825
- written: false
826
- };
827
- if (nextResult.status === "success") {
828
- const htmlDigest = hashHtml(nextResult.html);
829
- const shouldWrite = effectiveMode === "full" || !previous || previous.htmlHash !== htmlDigest || !outputFileExists(options.outputDir, descriptor.filePath);
830
- nextResult.written = shouldWrite;
831
- if (!shouldWrite) cacheHits += 1;
832
- nextManifestRoutes.push({
863
+ };
864
+ if (nextResult.status === "success") {
865
+ const htmlDigest = hashHtml(nextResult.html);
866
+ const shouldWrite = effectiveMode === "full" || !previous || previous.htmlHash !== htmlDigest || !outputFileExists(options.outputDir, descriptor.filePath);
867
+ nextResult.written = shouldWrite;
868
+ if (!shouldWrite) cacheHits += 1;
869
+ nextManifestRoutes.push({
870
+ routeId: descriptor.routeId,
871
+ path: descriptor.path,
872
+ filePath: descriptor.filePath,
873
+ invalidationKeys: descriptor.invalidationKeys.slice(),
874
+ htmlHash: htmlDigest,
875
+ lastStatus: "success"
876
+ });
877
+ } else nextManifestRoutes.push({
833
878
  routeId: descriptor.routeId,
834
879
  path: descriptor.path,
835
880
  filePath: descriptor.filePath,
836
881
  invalidationKeys: descriptor.invalidationKeys.slice(),
837
- htmlHash: htmlDigest,
838
- lastStatus: "success"
882
+ htmlHash: previous?.htmlHash ?? null,
883
+ lastStatus: "error"
839
884
  });
840
- } else nextManifestRoutes.push({
841
- routeId: descriptor.routeId,
842
- path: descriptor.path,
843
- filePath: descriptor.filePath,
844
- invalidationKeys: descriptor.invalidationKeys.slice(),
845
- htmlHash: previous?.htmlHash ?? null,
846
- lastStatus: "error"
847
- });
848
- routeResultsById.set(descriptor.routeId, nextResult);
849
- }
850
- const removedResults = collectRemovedRouteResults(previousManifest, currentRouteIds);
851
- const routeResults = [];
852
- for (const routeId of splitRoutes.outputRouteIds) {
853
- const routeResult = routeResultsById.get(routeId);
854
- if (routeResult) routeResults.push(routeResult);
855
- }
856
- routeResults.push(...removedResults);
857
- result = generateSSGResult(routeResults, {
858
- mode: effectiveMode,
859
- cacheHits,
860
- invalidatedKeys: changedKeys,
861
- invalidatedRoutes: changedRoutes
862
- });
863
- if (effectiveMode === "full" && result.failed > 0) return result;
864
- const targetOutputDir = effectiveMode === "full" ? await createStagingDirectory(options.outputDir) : options.outputDir;
865
- try {
866
- const writeStartTime = performance.now();
867
- await writeStaticFiles(routeResults, targetOutputDir, { concurrency: resolvedConcurrency });
868
- performance.now() - writeStartTime;
869
- await writeMetadata(resultToMetadata(result), targetOutputDir);
870
- await writeIncrementalManifest({
871
- schemaVersion: 1,
872
- seed,
885
+ routeResultsById.set(descriptor.routeId, nextResult);
886
+ }
887
+ const removedResults = collectRemovedRouteResults(previousManifest, currentRouteIds);
888
+ const routeResults = [];
889
+ for (const routeId of splitRoutes.outputRouteIds) {
890
+ const routeResult = routeResultsById.get(routeId);
891
+ if (routeResult) routeResults.push(routeResult);
892
+ }
893
+ routeResults.push(...removedResults);
894
+ result = generateSSGResult(routeResults, {
873
895
  mode: effectiveMode,
874
- routes: nextManifestRoutes
875
- }, targetOutputDir);
876
- await copyStaticAssets(options.assets, targetOutputDir);
877
- if (effectiveMode === "full") await replaceOutputDirectory(targetOutputDir, options.outputDir);
878
- } catch (error) {
879
- if (effectiveMode === "full") await fs.rm(targetOutputDir, {
880
- recursive: true,
881
- force: true
896
+ cacheHits,
897
+ invalidatedKeys: changedKeys,
898
+ invalidatedRoutes: changedRoutes
882
899
  });
883
- throw error;
884
- }
885
- return result;
900
+ if (effectiveMode === "full" && result.failed > 0) {
901
+ for (const routeResult of result.routes) if (routeResult.status === "success") routeResult.written = false;
902
+ return result;
903
+ }
904
+ const targetOutputDir = effectiveMode === "full" ? await createStagingDirectory(options.outputDir) : options.outputDir;
905
+ try {
906
+ const writeStartTime = performance.now();
907
+ await writeStaticFiles(routeResults, targetOutputDir, { concurrency: resolvedConcurrency });
908
+ performance.now() - writeStartTime;
909
+ await writeMetadata(resultToMetadata(result), targetOutputDir);
910
+ await writeIncrementalManifest({
911
+ schemaVersion: 1,
912
+ seed,
913
+ mode: effectiveMode,
914
+ routes: nextManifestRoutes
915
+ }, targetOutputDir);
916
+ await copyStaticAssets(options.assets, targetOutputDir);
917
+ if (effectiveMode === "full") await replaceOutputDirectory(targetOutputDir, options.outputDir);
918
+ } catch (error) {
919
+ if (effectiveMode === "full") await fs.rm(targetOutputDir, {
920
+ recursive: true,
921
+ force: true
922
+ });
923
+ throw error;
924
+ }
925
+ return result;
926
+ });
886
927
  },
887
928
  /**
888
929
  * Get effective config in a serializable form for diagnostics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/askr",
3
- "version": "0.0.95",
3
+ "version": "0.0.96",
4
4
  "description": "Actor-backed deterministic UI framework",
5
5
  "keywords": [
6
6
  "askr",