@sasjs/cli 6.30.0 → 6.31.0
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/build/commands/build/internal/mockSite/assets/api-console.css +365 -0
- package/build/commands/build/internal/mockSite/assets/api-console.js +762 -0
- package/build/commands/build/internal/mockSite/assets/api-index.html +83 -0
- package/build/commands/build/internal/mockSite/assets/boot.js +23 -1
- package/build/commands/build/internal/mockSite/assets/persist.js +76 -5
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +413 -50
- package/build/commands/build/internal/mockSite/assets/sw.js +156 -15
- package/build/commands/build/internal/mockSite/mockSite.d.ts +25 -0
- package/build/commands/build/internal/mockSite/mockSite.js +311 -24
- package/build/commands/build/internal/mockSite/spec/apiConsole.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/apiConsole.spec.js +1371 -0
- package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.js +303 -0
- package/build/commands/build/internal/mockSite/spec/boot.spec.js +50 -3
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +217 -3
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +179 -1
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +546 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +407 -53
- package/build/commands/version/spec/version.spec.js +11 -1
- package/build/commands/version/version.d.ts +5 -3
- package/build/commands/version/version.js +17 -7
- package/build/types/mockWebBasePath.d.ts +9 -0
- package/package.json +13 -32
|
@@ -566,11 +566,77 @@ const DIRS = new Set(['/'])
|
|
|
566
566
|
// multi-megabyte seeded file does not scan it again.
|
|
567
567
|
const SIZES = new Map()
|
|
568
568
|
|
|
569
|
+
// ------------------------------------------------------------- sharded drive
|
|
570
|
+
/**
|
|
571
|
+
* The pieces a captured drive ships in.
|
|
572
|
+
*
|
|
573
|
+
* A site that ships its drive as one file makes every visitor download all of
|
|
574
|
+
* it before the first page can render - on a demonstration site that is every
|
|
575
|
+
* app's bundle behind a list of tiles. The build splits the capture instead: one
|
|
576
|
+
* small boot shard with the files a first paint needs, and a shard per large
|
|
577
|
+
* folder or file, fetched when something reads a path in it.
|
|
578
|
+
*
|
|
579
|
+
* `PENDING` is what makes that invisible to everything above: the paths, folders
|
|
580
|
+
* and sizes of the files not yet in memory are known from the index, so a
|
|
581
|
+
* listing, a stat and an existence check answer exactly as they would with the
|
|
582
|
+
* contents present. Only a read has to fail, because it cannot wait - the
|
|
583
|
+
* request that reads preloads the piece first (see `ensurePaths`), and the code
|
|
584
|
+
* that reads without asking gets a refusal it can retry on rather than an empty
|
|
585
|
+
* file.
|
|
586
|
+
*/
|
|
587
|
+
const PENDING = new Map() // abs path -> { shard, bytes }
|
|
588
|
+
const SHARDS = new Map() // shard id -> { file, state, promise }
|
|
589
|
+
// paths the visitor deleted, and which the page hands back at boot. A sharded
|
|
590
|
+
// drive cannot rely on absence any more: the index lists every file the site
|
|
591
|
+
// ships, so a capture file deleted before a reload would return with the next
|
|
592
|
+
// index. A tombstone is what keeps it deleted.
|
|
593
|
+
const DELETED = new Set()
|
|
594
|
+
let bootShard = 0
|
|
595
|
+
// how many pieces this build split its capture into, from mock-config.json: 0
|
|
596
|
+
// (or absent) means an unsharded build, whose drive arrives whole
|
|
597
|
+
let SHARD_COUNT = 0
|
|
598
|
+
// whether the config was actually read. A config that failed to load says nothing
|
|
599
|
+
// about the capture, and treating it as "unsharded" would restore a drive of the
|
|
600
|
+
// visitor's changed files only - every file they had never loaded then reads
|
|
601
|
+
// ENOENT with nothing logged - so the two states are kept apart.
|
|
602
|
+
let CONFIG_LOADED = false
|
|
603
|
+
|
|
604
|
+
/** The refusal a read of a path that is still on the site raises. */
|
|
605
|
+
const PENDING_CODE = 'EDRIVEPENDING'
|
|
606
|
+
|
|
607
|
+
const pendingError = (p) => {
|
|
608
|
+
const err = new Error(
|
|
609
|
+
`The drive file '${String(p)}' is in a shard this host has not fetched yet`
|
|
610
|
+
)
|
|
611
|
+
err.code = PENDING_CODE
|
|
612
|
+
// the path, so the host can fetch that piece rather than the whole drive
|
|
613
|
+
err.drivePath = P.resolve(String(p))
|
|
614
|
+
err.path = err.drivePath
|
|
615
|
+
return err
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Puts contents at a path.
|
|
620
|
+
*
|
|
621
|
+
* One function for every write, because a write has to do three things that are
|
|
622
|
+
* easy to do in two: store the value, drop the cached size, and take the path out
|
|
623
|
+
* of the shard index. A file whose bytes are still on the site and whose contents
|
|
624
|
+
* are also in memory is listed twice and counted twice, and a piece fetched later
|
|
625
|
+
* would put the capture's copy back over the visitor's.
|
|
626
|
+
*/
|
|
627
|
+
const putEntry = (abs, contents) => {
|
|
628
|
+
FILES.set(abs, contents)
|
|
629
|
+
SIZES.delete(abs)
|
|
630
|
+
PENDING.delete(abs)
|
|
631
|
+
}
|
|
632
|
+
|
|
569
633
|
/** The byte length of an entry on the drive, measured once per stored value. */
|
|
570
634
|
const entrySizeAt = (abs) => {
|
|
571
635
|
const contents = FILES.get(abs)
|
|
572
636
|
const cached = SIZES.get(abs)
|
|
573
637
|
if (cached && cached.contents === contents) return cached.size
|
|
638
|
+
const pending = PENDING.get(abs)
|
|
639
|
+
if (contents === undefined && pending) return pending.bytes
|
|
574
640
|
const size = entrySize(contents)
|
|
575
641
|
SIZES.set(abs, { contents, size })
|
|
576
642
|
return size
|
|
@@ -585,6 +651,11 @@ const dirty = new Map() // abs path -> contents, or null when deleted
|
|
|
585
651
|
|
|
586
652
|
const markDirty = (path) => {
|
|
587
653
|
const contents = FILES.has(path) ? FILES.get(path) : null
|
|
654
|
+
// one place decides what a deletion means, because every write and every
|
|
655
|
+
// remove passes through here: a file that is gone is tombstoned, and writing
|
|
656
|
+
// it again clears the tombstone
|
|
657
|
+
if (contents === null) DELETED.add(path)
|
|
658
|
+
else DELETED.delete(path)
|
|
588
659
|
dirty.set(path, contents)
|
|
589
660
|
}
|
|
590
661
|
|
|
@@ -601,25 +672,150 @@ const dumpAll = () => {
|
|
|
601
672
|
return out
|
|
602
673
|
}
|
|
603
674
|
|
|
604
|
-
|
|
675
|
+
/**
|
|
676
|
+
* Puts a drive in memory: the files that arrive with it, and the index of the
|
|
677
|
+
* pieces that did not.
|
|
678
|
+
*
|
|
679
|
+
* One function for both paths - a site's own snapshot and a drive the page
|
|
680
|
+
* restored - because the index has to be applied either way: it is what makes a
|
|
681
|
+
* file that is still on the site exist for a listing and a stat, and it is what
|
|
682
|
+
* a read goes back for. Files handed in here are contents in hand, so they win
|
|
683
|
+
* over the index and their shard is never fetched.
|
|
684
|
+
*/
|
|
685
|
+
const installDrive = (index, files, deleted) => {
|
|
605
686
|
FILES.clear()
|
|
606
687
|
SIZES.clear()
|
|
607
688
|
DIRS.clear()
|
|
689
|
+
PENDING.clear()
|
|
690
|
+
SHARDS.clear()
|
|
691
|
+
DELETED.clear()
|
|
692
|
+
bootShard = 0
|
|
608
693
|
DIRS.add('/')
|
|
609
694
|
mkdirp(DRIVE_APP)
|
|
610
695
|
mkdirp(`${DRIVE_ROOT}/files`)
|
|
696
|
+
for (const path of deleted || []) DELETED.add(P.resolve(String(path)))
|
|
697
|
+
if (index) applyIndex(index)
|
|
611
698
|
for (const [path, contents] of Object.entries(files || {})) {
|
|
612
699
|
if (typeof contents !== 'string') continue
|
|
613
|
-
|
|
614
|
-
|
|
700
|
+
const abs = P.resolve(path)
|
|
701
|
+
FILES.set(abs, contents)
|
|
702
|
+
PENDING.delete(abs)
|
|
703
|
+
SIZES.delete(abs)
|
|
704
|
+
mkdirp(P.dirname(abs))
|
|
615
705
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Records the shards a drive ships in and the entry each file belongs to.
|
|
710
|
+
*
|
|
711
|
+
* Nothing is fetched here: the paths, folders and sizes answer for the whole
|
|
712
|
+
* drive from this point, so only a read has to go back for the bytes.
|
|
713
|
+
*/
|
|
714
|
+
const applyIndex = (index) => {
|
|
715
|
+
bootShard = typeof index.boot === 'number' ? index.boot : 0
|
|
716
|
+
for (const shard of index.shards || []) {
|
|
717
|
+
if (!shard || typeof shard.file !== 'string') continue
|
|
718
|
+
SHARDS.set(shard.id, { file: shard.file, state: 'unloaded', promise: null })
|
|
719
|
+
}
|
|
720
|
+
for (const [path, entry] of Object.entries(index.entries || {})) {
|
|
721
|
+
const abs = P.resolve(path)
|
|
722
|
+
// a restored or written file is what the visitor has: the site's copy of it
|
|
723
|
+
// is not fetched over the top. And a path the visitor deleted stays deleted -
|
|
724
|
+
// the index knows the site still ships it, which is exactly why the
|
|
725
|
+
// tombstone has to win here.
|
|
726
|
+
if (FILES.has(abs) || DELETED.has(abs)) continue
|
|
727
|
+
const bytes = entry && typeof entry.bytes === 'number' ? entry.bytes : 0
|
|
728
|
+
PENDING.set(abs, { shard: entry.shard, bytes })
|
|
729
|
+
SIZES.set(abs, { contents: undefined, size: bytes })
|
|
730
|
+
mkdirp(P.dirname(abs))
|
|
621
731
|
}
|
|
622
|
-
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Fetches one shard into memory.
|
|
736
|
+
*
|
|
737
|
+
* Concurrent readers of the same shard share the request, and a failed one is
|
|
738
|
+
* left unloaded so the next read retries rather than believing the drive is
|
|
739
|
+
* short of a file. A file that is already in memory - a restored drive, a write
|
|
740
|
+
* the visitor made - is never overwritten by a shard: the drive they have is the
|
|
741
|
+
* one they are working on.
|
|
742
|
+
*/
|
|
743
|
+
const loadShard = (id) => {
|
|
744
|
+
const shard = SHARDS.get(id)
|
|
745
|
+
if (!shard || shard.state === 'loaded') return Promise.resolve()
|
|
746
|
+
if (shard.promise) return shard.promise
|
|
747
|
+
|
|
748
|
+
shard.state = 'loading'
|
|
749
|
+
shard.promise = (async () => {
|
|
750
|
+
const res = await fetch(shard.file, { cache: 'no-store' })
|
|
751
|
+
if (!res.ok)
|
|
752
|
+
throw new Error(
|
|
753
|
+
'The drive shard ' +
|
|
754
|
+
shard.file +
|
|
755
|
+
' could not be read (' +
|
|
756
|
+
res.status +
|
|
757
|
+
'), so the drive is incomplete'
|
|
758
|
+
)
|
|
759
|
+
const body = await res.json()
|
|
760
|
+
if (body && body.format !== undefined && body.format !== DRIVE_FORMAT)
|
|
761
|
+
throw new Error(
|
|
762
|
+
'The drive in ' +
|
|
763
|
+
shard.file +
|
|
764
|
+
' is format ' +
|
|
765
|
+
JSON.stringify(body.format) +
|
|
766
|
+
', and this runtime speaks format ' +
|
|
767
|
+
DRIVE_FORMAT +
|
|
768
|
+
'. Rebuild the mock site with the matching @sasjs/cli.'
|
|
769
|
+
)
|
|
770
|
+
for (const [path, contents] of Object.entries((body && body.files) || {})) {
|
|
771
|
+
if (typeof contents !== 'string') continue
|
|
772
|
+
const abs = P.resolve(path)
|
|
773
|
+
PENDING.delete(abs)
|
|
774
|
+
// a deleted file stays deleted, even when the piece holding it is fetched
|
|
775
|
+
if (FILES.has(abs) || DELETED.has(abs)) continue
|
|
776
|
+
FILES.set(abs, contents)
|
|
777
|
+
SIZES.delete(abs)
|
|
778
|
+
mkdirp(P.dirname(abs))
|
|
779
|
+
}
|
|
780
|
+
shard.state = 'loaded'
|
|
781
|
+
return true
|
|
782
|
+
})().catch((e) => {
|
|
783
|
+
shard.state = 'unloaded'
|
|
784
|
+
shard.promise = null
|
|
785
|
+
throw e
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
return shard.promise
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Fetches the pieces holding these paths, and nothing else. */
|
|
792
|
+
const ensurePaths = async (paths) => {
|
|
793
|
+
const ids = new Set()
|
|
794
|
+
for (const path of paths || []) {
|
|
795
|
+
// the API names a path under the drive (`/data/x.csv`) and a program names
|
|
796
|
+
// an absolute one; both resolve to an entry here
|
|
797
|
+
let abs = P.resolve(String(path))
|
|
798
|
+
if (!PENDING.has(abs)) {
|
|
799
|
+
try {
|
|
800
|
+
abs = absDrivePath(path)
|
|
801
|
+
} catch (e) {
|
|
802
|
+
continue
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const pending = PENDING.get(abs)
|
|
806
|
+
if (pending && pending.shard !== undefined) ids.add(pending.shard)
|
|
807
|
+
}
|
|
808
|
+
if (!ids.size) return false
|
|
809
|
+
await Promise.all([...ids].map((id) => loadShard(id)))
|
|
810
|
+
return true
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Fetches the rest of the drive. The answer to a read that could not wait. */
|
|
814
|
+
const ensureAll = async () => {
|
|
815
|
+
const ids = [...SHARDS.keys()]
|
|
816
|
+
if (!ids.length) return false
|
|
817
|
+
await Promise.all(ids.map((id) => loadShard(id)))
|
|
818
|
+
return true
|
|
623
819
|
}
|
|
624
820
|
|
|
625
821
|
const mkdirp = (dir) => {
|
|
@@ -653,6 +849,7 @@ const FS = {
|
|
|
653
849
|
readFileSync(p, options) {
|
|
654
850
|
const abs = P.resolve(String(p))
|
|
655
851
|
if (!FILES.has(abs)) {
|
|
852
|
+
if (PENDING.has(abs)) throw pendingError(p)
|
|
656
853
|
const err = new Error(
|
|
657
854
|
`ENOENT: no such file or directory, open '${String(p)}'`
|
|
658
855
|
)
|
|
@@ -683,7 +880,8 @@ const FS = {
|
|
|
683
880
|
},
|
|
684
881
|
existsSync(p) {
|
|
685
882
|
const abs = P.resolve(String(p))
|
|
686
|
-
|
|
883
|
+
// a pending entry exists: only its bytes are still on the site
|
|
884
|
+
return FILES.has(abs) || PENDING.has(abs) || DIRS.has(abs)
|
|
687
885
|
},
|
|
688
886
|
writeFileSync(p, data, options) {
|
|
689
887
|
const abs = P.resolve(String(p))
|
|
@@ -693,7 +891,12 @@ const FS = {
|
|
|
693
891
|
// file in chunks produces a whole file on the server and would otherwise
|
|
694
892
|
// produce its last chunk here.
|
|
695
893
|
const { encoding, flag } = writeOptions(options)
|
|
696
|
-
|
|
894
|
+
// On a sharded drive the capture's bytes are in PENDING, not FILES, until
|
|
895
|
+
// something reads them: appending there would replace them with the appended
|
|
896
|
+
// chunk and persist that as the visitor's version. Refuse instead, so the
|
|
897
|
+
// host fetches the piece and replays the write the way it does for a read.
|
|
898
|
+
const appending = flag === 'a' && (FILES.has(abs) || PENDING.has(abs))
|
|
899
|
+
if (appending && !FILES.has(abs)) throw pendingError(p)
|
|
697
900
|
const before = appending ? entryBytes(FILES.get(abs)) : null
|
|
698
901
|
const stored = appending && isBinary(FILES.get(abs))
|
|
699
902
|
// text, or the bytes node lets a service write: an array, a number or null
|
|
@@ -706,7 +909,7 @@ const FS = {
|
|
|
706
909
|
`The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received ${describeValue(data)}`
|
|
707
910
|
)
|
|
708
911
|
const written = appending ? joinBytes(before, bytes) : bytes
|
|
709
|
-
|
|
912
|
+
putEntry(abs, BINARY_PREFIX + bytesToBase64(written))
|
|
710
913
|
markDirty(abs)
|
|
711
914
|
return
|
|
712
915
|
}
|
|
@@ -716,13 +919,13 @@ const FS = {
|
|
|
716
919
|
// as the bytes it produces
|
|
717
920
|
if (how === 'utf8' && !stored) {
|
|
718
921
|
const text = appending ? entryText(FILES.get(abs)) + data : data
|
|
719
|
-
|
|
922
|
+
putEntry(abs, encodeText(text))
|
|
720
923
|
markDirty(abs)
|
|
721
924
|
return
|
|
722
925
|
}
|
|
723
926
|
const bytes = BUFFERS.from(data, how)
|
|
724
927
|
const written = appending ? joinBytes(before, bytes) : bytes
|
|
725
|
-
|
|
928
|
+
putEntry(abs, BINARY_PREFIX + bytesToBase64(written))
|
|
726
929
|
markDirty(abs)
|
|
727
930
|
},
|
|
728
931
|
writeFile(p, data, options, cb) {
|
|
@@ -756,7 +959,9 @@ const FS = {
|
|
|
756
959
|
const abs = P.resolve(String(p))
|
|
757
960
|
const pre = abs === '/' ? '/' : abs + '/'
|
|
758
961
|
const out = new Set()
|
|
759
|
-
|
|
962
|
+
// both maps: a folder lists the files whose bytes are still on the site as
|
|
963
|
+
// well as the ones in memory, or a pending shard would look like a delete
|
|
964
|
+
for (const k of [...FILES.keys(), ...PENDING.keys()]) {
|
|
760
965
|
if (k.startsWith(pre)) {
|
|
761
966
|
const rest = k.slice(pre.length)
|
|
762
967
|
if (rest && !rest.includes('/')) out.add(rest)
|
|
@@ -774,7 +979,7 @@ const FS = {
|
|
|
774
979
|
const abs = P.resolve(String(p))
|
|
775
980
|
if (DIRS.has(abs))
|
|
776
981
|
return { isDirectory: () => true, isFile: () => false, size: 0 }
|
|
777
|
-
if (FILES.has(abs)) {
|
|
982
|
+
if (FILES.has(abs) || PENDING.has(abs)) {
|
|
778
983
|
const size = entrySizeAt(abs)
|
|
779
984
|
return { isDirectory: () => false, isFile: () => true, size }
|
|
780
985
|
}
|
|
@@ -788,6 +993,9 @@ const FS = {
|
|
|
788
993
|
const abs = P.resolve(String(p))
|
|
789
994
|
FILES.delete(abs)
|
|
790
995
|
SIZES.delete(abs)
|
|
996
|
+
// a pending entry is deleted like a loaded one: the shard it came from is
|
|
997
|
+
// never fetched, so nothing can bring it back
|
|
998
|
+
PENDING.delete(abs)
|
|
791
999
|
markDirty(abs)
|
|
792
1000
|
},
|
|
793
1001
|
copyFileSync(from, to) {
|
|
@@ -798,7 +1006,7 @@ const FS = {
|
|
|
798
1006
|
// text file is not re-escaped
|
|
799
1007
|
const abs = P.resolve(String(to))
|
|
800
1008
|
mkdirp(P.dirname(abs))
|
|
801
|
-
|
|
1009
|
+
putEntry(abs, FILES.get(src))
|
|
802
1010
|
markDirty(abs)
|
|
803
1011
|
}
|
|
804
1012
|
}
|
|
@@ -876,6 +1084,13 @@ const loadMockConfig = async () => {
|
|
|
876
1084
|
if (cfg && typeof cfg.seedPath === 'string' && cfg.seedPath.trim()) {
|
|
877
1085
|
SEED_PATH = cfg.seedPath
|
|
878
1086
|
}
|
|
1087
|
+
// how many pieces the capture was split into, so a restore knows whether
|
|
1088
|
+
// there is an index to read before it asks for one: an unsharded build (every
|
|
1089
|
+
// site built before the split) must not pay a request per visit for a file
|
|
1090
|
+
// that is not there
|
|
1091
|
+
if (cfg && typeof cfg.snapshotShards === 'number')
|
|
1092
|
+
SHARD_COUNT = cfg.snapshotShards
|
|
1093
|
+
CONFIG_LOADED = true
|
|
879
1094
|
} catch (e) {
|
|
880
1095
|
/* keep the defaults */
|
|
881
1096
|
}
|
|
@@ -1198,13 +1413,12 @@ const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
|
|
|
1198
1413
|
*/
|
|
1199
1414
|
const storeEntry = (abs, data) => {
|
|
1200
1415
|
const bytes = asBytes(data)
|
|
1201
|
-
|
|
1416
|
+
putEntry(
|
|
1202
1417
|
abs,
|
|
1203
1418
|
bytes
|
|
1204
1419
|
? BINARY_PREFIX + bytesToBase64(bytes)
|
|
1205
1420
|
: encodeText(String(data === undefined || data === null ? '' : data))
|
|
1206
1421
|
)
|
|
1207
|
-
SIZES.delete(abs)
|
|
1208
1422
|
markDirty(abs)
|
|
1209
1423
|
}
|
|
1210
1424
|
|
|
@@ -1273,6 +1487,9 @@ const driveStore = {
|
|
|
1273
1487
|
async read(drivePath) {
|
|
1274
1488
|
const abs = absDrivePath(drivePath)
|
|
1275
1489
|
const stored = FILES.get(abs)
|
|
1490
|
+
// a file whose bytes are still on the site is not a missing file: the code
|
|
1491
|
+
// lets the host fetch the piece and answer the request again
|
|
1492
|
+
if (stored === undefined && PENDING.has(abs)) throw pendingError(abs)
|
|
1276
1493
|
if (stored === undefined) {
|
|
1277
1494
|
// the API checks existence first, so this is a store that was handed a
|
|
1278
1495
|
// path it does not hold - refused in the drive's own words rather than
|
|
@@ -1297,13 +1514,14 @@ const driveStore = {
|
|
|
1297
1514
|
|
|
1298
1515
|
async exists(drivePath) {
|
|
1299
1516
|
const abs = absDrivePath(drivePath)
|
|
1300
|
-
return FILES.has(abs) || DIRS.has(abs)
|
|
1517
|
+
return FILES.has(abs) || PENDING.has(abs) || DIRS.has(abs)
|
|
1301
1518
|
},
|
|
1302
1519
|
|
|
1303
1520
|
async stat(drivePath) {
|
|
1304
1521
|
const abs = absDrivePath(drivePath)
|
|
1305
1522
|
if (DIRS.has(abs)) return { directory: true, size: 0 }
|
|
1306
|
-
if (FILES.has(abs)
|
|
1523
|
+
if (FILES.has(abs) || PENDING.has(abs))
|
|
1524
|
+
return { directory: false, size: entrySizeAt(abs) }
|
|
1307
1525
|
const err = new Error(
|
|
1308
1526
|
`ENOENT: no such file or directory, stat '${drivePath}'`
|
|
1309
1527
|
)
|
|
@@ -1316,7 +1534,9 @@ const driveStore = {
|
|
|
1316
1534
|
const pre = abs === '/' ? '/' : abs + '/'
|
|
1317
1535
|
const files = []
|
|
1318
1536
|
const folders = []
|
|
1319
|
-
|
|
1537
|
+
// a pending file is listed like a loaded one: its bytes are elsewhere, not
|
|
1538
|
+
// its name
|
|
1539
|
+
for (const key of [...FILES.keys(), ...PENDING.keys()]) {
|
|
1320
1540
|
if (!key.startsWith(pre)) continue
|
|
1321
1541
|
const rest = key.slice(pre.length)
|
|
1322
1542
|
if (rest && !rest.includes('/')) files.push(rest)
|
|
@@ -1342,17 +1562,19 @@ const driveStore = {
|
|
|
1342
1562
|
*/
|
|
1343
1563
|
async remove(drivePath) {
|
|
1344
1564
|
const abs = absDrivePath(drivePath)
|
|
1345
|
-
if (FILES.has(abs)) {
|
|
1565
|
+
if (FILES.has(abs) || PENDING.has(abs)) {
|
|
1346
1566
|
FILES.delete(abs)
|
|
1347
1567
|
SIZES.delete(abs)
|
|
1568
|
+
PENDING.delete(abs)
|
|
1348
1569
|
markDirty(abs)
|
|
1349
1570
|
return
|
|
1350
1571
|
}
|
|
1351
1572
|
const prefix = abs + '/'
|
|
1352
|
-
for (const path of [...FILES.keys()]) {
|
|
1573
|
+
for (const path of [...FILES.keys(), ...PENDING.keys()]) {
|
|
1353
1574
|
if (!path.startsWith(prefix)) continue
|
|
1354
1575
|
FILES.delete(path)
|
|
1355
1576
|
SIZES.delete(path)
|
|
1577
|
+
PENDING.delete(path)
|
|
1356
1578
|
markDirty(path)
|
|
1357
1579
|
}
|
|
1358
1580
|
for (const dir of [...DIRS]) {
|
|
@@ -1365,9 +1587,12 @@ const driveStore = {
|
|
|
1365
1587
|
const from = absDrivePath(fromPath)
|
|
1366
1588
|
const to = absDrivePath(toPath)
|
|
1367
1589
|
const moveEntry = (source, target) => {
|
|
1368
|
-
|
|
1590
|
+
putEntry(target, FILES.get(source))
|
|
1369
1591
|
FILES.delete(source)
|
|
1370
1592
|
SIZES.delete(source)
|
|
1593
|
+
// the source is gone from the drive, so it cannot still be a piece of the
|
|
1594
|
+
// capture either
|
|
1595
|
+
PENDING.delete(source)
|
|
1371
1596
|
mkdirp(P.dirname(target))
|
|
1372
1597
|
markDirty(target)
|
|
1373
1598
|
markDirty(source)
|
|
@@ -1412,8 +1637,6 @@ const fetchDrive = async () => {
|
|
|
1412
1637
|
const res = await fetch(url, { cache: 'no-store' })
|
|
1413
1638
|
if (!res.ok) continue
|
|
1414
1639
|
const body = await res.json()
|
|
1415
|
-
const files = body && body.files ? body.files : body
|
|
1416
|
-
if (!files || typeof files !== 'object') continue
|
|
1417
1640
|
|
|
1418
1641
|
// A file that declares a format this runtime does not speak is refused
|
|
1419
1642
|
// rather than misread: the markers inside it would decode as text, and the
|
|
@@ -1431,11 +1654,26 @@ const fetchDrive = async () => {
|
|
|
1431
1654
|
'. Rebuild the mock site with the matching @sasjs/cli.'
|
|
1432
1655
|
)
|
|
1433
1656
|
|
|
1434
|
-
|
|
1657
|
+
// A sharded capture is an index rather than contents: the build splits a
|
|
1658
|
+
// drive so a first page does not download every app's bundle, and the
|
|
1659
|
+
// files arrive per shard as something reads them.
|
|
1660
|
+
if (body && body.sharded) {
|
|
1661
|
+
if (!body.entries || typeof body.entries !== 'object')
|
|
1662
|
+
throw new Error(
|
|
1663
|
+
'The drive in ' + url + ' is sharded but carries no entry index'
|
|
1664
|
+
)
|
|
1665
|
+
return { source, files: {}, index: body }
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const files = body && body.files ? body.files : body
|
|
1669
|
+
if (!files || typeof files !== 'object') continue
|
|
1670
|
+
|
|
1671
|
+
return { source, files, index: null }
|
|
1435
1672
|
} catch (e) {
|
|
1436
1673
|
// a format mismatch is not a reason to try the other file: it is the file
|
|
1437
1674
|
// the operator meant to ship, and falling through would hide it
|
|
1438
1675
|
if (e && /is format /.test(e.message)) throw e
|
|
1676
|
+
if (e && /sharded but carries no entry index/.test(e.message)) throw e
|
|
1439
1677
|
/* fall through to the next source */
|
|
1440
1678
|
}
|
|
1441
1679
|
}
|
|
@@ -1449,31 +1687,96 @@ const fetchDrive = async () => {
|
|
|
1449
1687
|
)
|
|
1450
1688
|
}
|
|
1451
1689
|
|
|
1690
|
+
/**
|
|
1691
|
+
* The index of a sharded drive, read even when the page hands one in.
|
|
1692
|
+
*
|
|
1693
|
+
* A restore is what this visitor has stored, and it is not the whole drive: the
|
|
1694
|
+
* parts of this build they never loaded are still on the site, so without the
|
|
1695
|
+
* index a second visit would find every app bundle missing. A site with no
|
|
1696
|
+
* snapshot, or an unsharded one, answers null and the restored drive stands on
|
|
1697
|
+
* its own.
|
|
1698
|
+
*/
|
|
1699
|
+
const fetchSnapshotIndex = async () => {
|
|
1700
|
+
try {
|
|
1701
|
+
const res = await fetch(SNAPSHOT_PATH, { cache: 'no-store' })
|
|
1702
|
+
// 404 is the one answer that means "this site ships no snapshot". Anything
|
|
1703
|
+
// else - a deploy caught half-written, a truncated body, a dropped
|
|
1704
|
+
// connection - leaves the drive incomplete in a way nobody would see: the
|
|
1705
|
+
// restored drive is the visitor's changed files and every captured file they
|
|
1706
|
+
// never loaded reads ENOENT, with nothing logged.
|
|
1707
|
+
if (res.status === 404) return null
|
|
1708
|
+
if (!res.ok) {
|
|
1709
|
+
const err = new Error(
|
|
1710
|
+
'The drive index at ' +
|
|
1711
|
+
SNAPSHOT_PATH +
|
|
1712
|
+
' answered ' +
|
|
1713
|
+
res.status +
|
|
1714
|
+
', so this drive cannot be read whole.'
|
|
1715
|
+
)
|
|
1716
|
+
// marked, so the catch below reports it as it is rather than wrapping it
|
|
1717
|
+
err.driveUnreadable = true
|
|
1718
|
+
throw err
|
|
1719
|
+
}
|
|
1720
|
+
const body = await res.json()
|
|
1721
|
+
if (!body || !body.sharded) return null
|
|
1722
|
+
if (body.format !== undefined && body.format !== DRIVE_FORMAT)
|
|
1723
|
+
throw new Error(
|
|
1724
|
+
'The drive in ' +
|
|
1725
|
+
SNAPSHOT_PATH +
|
|
1726
|
+
' is format ' +
|
|
1727
|
+
JSON.stringify(body.format) +
|
|
1728
|
+
', and this runtime speaks format ' +
|
|
1729
|
+
DRIVE_FORMAT +
|
|
1730
|
+
'. Rebuild the mock site with the matching @sasjs/cli.'
|
|
1731
|
+
)
|
|
1732
|
+
return body
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
// a format mismatch and a failed read both mean the index cannot be used,
|
|
1735
|
+
// and both are reported: only "no snapshot here" is a normal answer
|
|
1736
|
+
if (e && /is format /.test(e.message)) throw e
|
|
1737
|
+
if (e && e.driveUnreadable) throw e
|
|
1738
|
+
const err = new Error(
|
|
1739
|
+
'The drive index at ' +
|
|
1740
|
+
SNAPSHOT_PATH +
|
|
1741
|
+
' could not be read: ' +
|
|
1742
|
+
((e && e.message) || e)
|
|
1743
|
+
)
|
|
1744
|
+
err.driveUnreadable = true
|
|
1745
|
+
throw err
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1452
1749
|
const boot = async () => {
|
|
1453
1750
|
await loadMockConfig()
|
|
1454
1751
|
await loadWeboutPrelude()
|
|
1455
|
-
const { source, files: seed } = await fetchDrive()
|
|
1752
|
+
const { source, files: seed, index } = await fetchDrive()
|
|
1456
1753
|
// a seed replaces the drive, it does not merge into one: the worker survives
|
|
1457
1754
|
// page loads, so it can still hold the drive it was given last time, and any
|
|
1458
1755
|
// file only that drive had would otherwise outlive the build that wrote it
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1756
|
+
installDrive(index, seed)
|
|
1757
|
+
// the boot shard holds what a first paint reads - configuration, listings, an
|
|
1758
|
+
// app's own small files - and everything else waits until something reads it.
|
|
1759
|
+
// A boot shard that does not arrive (a half-uploaded deploy) must not stop the
|
|
1760
|
+
// page booting: the index is still worth having, because every read then names
|
|
1761
|
+
// the piece it could not fetch instead of the drive quietly starting empty.
|
|
1762
|
+
let bootShardError = null
|
|
1763
|
+
if (index)
|
|
1764
|
+
await loadShard(bootShard).catch((e) => {
|
|
1765
|
+
bootShardError = (e && e.message) || String(e)
|
|
1766
|
+
})
|
|
1469
1767
|
dirty.clear()
|
|
1470
1768
|
|
|
1471
1769
|
// No bootstrap: seeding a drive is an ordinary service call, made by the app
|
|
1472
1770
|
// (or an operator) the same way it would be against a server. The host knows
|
|
1473
1771
|
// nothing about what a project's services do.
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1772
|
+
//
|
|
1773
|
+
// What is in memory is marked dirty once, so the page persists it: on an
|
|
1774
|
+
// unsharded build that is the whole drive, and on a sharded one it is the boot
|
|
1775
|
+
// shard. The rest is not lost by that - it is still on the site, the index says
|
|
1776
|
+
// so, and the page's stored drive is what it has *changed*, with the index
|
|
1777
|
+
// supplying the pieces it never loaded. A file deleted here is tombstoned
|
|
1778
|
+
// (markDirty is what does it), so the index cannot bring it back on the next
|
|
1779
|
+
// visit.
|
|
1477
1780
|
for (const path of FILES.keys()) markDirty(path)
|
|
1478
1781
|
|
|
1479
1782
|
lastBoot = {
|
|
@@ -1481,6 +1784,10 @@ const boot = async () => {
|
|
|
1481
1784
|
restored: false,
|
|
1482
1785
|
source,
|
|
1483
1786
|
files: FILES.size,
|
|
1787
|
+
onSite: PENDING.size,
|
|
1788
|
+
// reported rather than thrown: the drive is usable, and every read that
|
|
1789
|
+
// needs the missing piece names it
|
|
1790
|
+
driveError: bootShardError,
|
|
1484
1791
|
at: new Date().toISOString()
|
|
1485
1792
|
}
|
|
1486
1793
|
return lastBoot
|
|
@@ -1518,17 +1825,58 @@ const api = {
|
|
|
1518
1825
|
return true
|
|
1519
1826
|
},
|
|
1520
1827
|
// Called by the host with the persisted drive; skips seeding entirely.
|
|
1521
|
-
load: (files) => {
|
|
1828
|
+
load: (files, deleted) => {
|
|
1522
1829
|
started = true
|
|
1523
1830
|
// The site's configuration and the webout() block are host files, not drive
|
|
1524
1831
|
// contents, so a restore needs them exactly as a seed does: only the seed
|
|
1525
1832
|
// used to load them, and every service written with webout() then failed
|
|
1526
1833
|
// with "webout is not defined" for a visitor the drive had been restored
|
|
1527
|
-
// for.
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1834
|
+
// for.
|
|
1835
|
+
//
|
|
1836
|
+
// The restored drive is installed synchronously, so a caller that reads it
|
|
1837
|
+
// straight after this call still can; the index of what the site still holds
|
|
1838
|
+
// arrives with the promise a worker awaits, and so does the boot shard.
|
|
1839
|
+
installDrive(null, files, deleted)
|
|
1840
|
+
lastBoot = {
|
|
1841
|
+
booted: true,
|
|
1842
|
+
restored: true,
|
|
1843
|
+
files: FILES.size,
|
|
1844
|
+
onSite: 0,
|
|
1845
|
+
at: new Date().toISOString()
|
|
1846
|
+
}
|
|
1847
|
+
ready = Promise.all([loadMockConfig(), loadWeboutPrelude()]).then(
|
|
1848
|
+
async () => {
|
|
1849
|
+
// Only a build that split its capture has an index to read: probing for
|
|
1850
|
+
// one on every restore would cost a request on the warm path, which is
|
|
1851
|
+
// the path this exists to keep cheap. A config that did not load is not
|
|
1852
|
+
// evidence of an unsharded capture - every build writes one - so that
|
|
1853
|
+
// case probes, and a 404 costs one request on a site that is broken
|
|
1854
|
+
// anyway.
|
|
1855
|
+
const sharded = CONFIG_LOADED ? SHARD_COUNT > 0 : true
|
|
1856
|
+
// an index that fails to arrive is reported, not read as "no snapshot":
|
|
1857
|
+
// restoring without it leaves the visitor with only their own changed
|
|
1858
|
+
// files, and every captured file they had not opened reads ENOENT
|
|
1859
|
+
const index = sharded
|
|
1860
|
+
? await fetchSnapshotIndex().catch((e) => {
|
|
1861
|
+
lastBoot.driveError = (e && e.message) || String(e)
|
|
1862
|
+
return null
|
|
1863
|
+
})
|
|
1864
|
+
: null
|
|
1865
|
+
if (index) {
|
|
1866
|
+
applyIndex(index)
|
|
1867
|
+
// A boot shard that does not arrive must not refuse a returning
|
|
1868
|
+
// visitor's drive: `boot()` reports it as `driveError` and carries on,
|
|
1869
|
+
// and a restore that rejected instead would answer the page's load
|
|
1870
|
+
// with an error and leave it with nothing. Every read that needs the
|
|
1871
|
+
// missing piece still names it.
|
|
1872
|
+
await loadShard(bootShard).catch((e) => {
|
|
1873
|
+
lastBoot.driveError = (e && e.message) || String(e)
|
|
1874
|
+
})
|
|
1875
|
+
lastBoot.onSite = PENDING.size
|
|
1876
|
+
}
|
|
1877
|
+
return lastBoot
|
|
1878
|
+
}
|
|
1879
|
+
)
|
|
1532
1880
|
if (resolveBoot) resolveBoot(ready)
|
|
1533
1881
|
return ready
|
|
1534
1882
|
},
|
|
@@ -1548,12 +1896,27 @@ const api = {
|
|
|
1548
1896
|
bootInfo: () => lastBoot,
|
|
1549
1897
|
stats: () => ({
|
|
1550
1898
|
files: FILES.size,
|
|
1899
|
+
// the files whose bytes are still on the site, and the pieces they are in
|
|
1900
|
+
onSite: PENDING.size,
|
|
1901
|
+
shards: SHARDS.size,
|
|
1551
1902
|
// the drive's total in bytes, measured per entry the way statSync does it -
|
|
1552
1903
|
// the stored representation's own length would count the marker and the
|
|
1553
|
-
// base64 expansion instead - and cached, so a second call scans nothing
|
|
1554
|
-
|
|
1904
|
+
// base64 expansion instead - and cached, so a second call scans nothing.
|
|
1905
|
+
// Both maps: on a sharded site the pieces nobody read are still part of the
|
|
1906
|
+
// drive, and their sizes are in the index.
|
|
1907
|
+
bytes: [...FILES.keys(), ...PENDING.keys()].reduce(
|
|
1908
|
+
(total, abs) => total + entrySizeAt(abs),
|
|
1909
|
+
0
|
|
1910
|
+
)
|
|
1555
1911
|
}),
|
|
1556
1912
|
run: (payload) => runProgram(payload),
|
|
1913
|
+
// the pieces a drive ships in: fetched by the host for the paths a request
|
|
1914
|
+
// names, and by a run for the whole drive - a program may read anything
|
|
1915
|
+
ensurePaths,
|
|
1916
|
+
ensureAll,
|
|
1917
|
+
// the refusal a read of a not-yet-fetched file raises, so the host can retry
|
|
1918
|
+
// on it rather than answer 500
|
|
1919
|
+
PENDING_CODE,
|
|
1557
1920
|
// the file a program names, so the host can tell a .sas program from a .js one
|
|
1558
1921
|
// and refuse the former instead of compiling SAS source as JavaScript
|
|
1559
1922
|
resolveProgram: (program) => resolveProgram(program),
|