@descix/frqtl-node 0.6.0-p1.1 → 0.6.0-p4.1
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/CLAUDE.md +1 -1
- package/README.md +3 -3
- package/dist/dist-manifest.json +27 -25
- package/dist/eyes/index.js +5 -2
- package/dist/frqtl/objects/FrqtlApp.js +28 -1
- package/dist/frqtl/objects/RecipeContext.js +178 -12
- package/dist/frqtl/objects/instruments/hosted-eyes.js +200 -29
- package/dist/frqtl/objects/record/Record.js +326 -0
- package/dist/frqtl/objects/record/open-record.js +22 -0
- package/dist/frqtl/parts/FrqtlVision.js +21 -6
- package/dist/shim/bundle/index.js +112 -96
- package/dist/shim/bundle/indexHtml.js +66 -39
- package/dist/shim/engine/router.js +2 -2
- package/dist/shim/index.js +1 -1
- package/dist/shim/notebook/format.js +1 -1
- package/dist/shim/notebook/store.js +2 -3
- package/dist/shim/project/index.js +16 -3
- package/dist/shim/project/link.js +181 -43
- package/dist/shim/project/manifest.js +151 -0
- package/dist/shim/project/parts-writer.js +9 -5
- package/dist/shim/project/record-format.js +345 -0
- package/dist/shim/project/specifiers.js +1 -1
- package/dist/shim/registry/index.js +1 -1
- package/dist/shim/registry/scene-source.js +44 -30
- package/dist/shim/registry/walkthrough.js +4 -3
- package/dist/shim/storage/codec-chains.js +88 -0
- package/dist/shim/storage/folders.js +205 -0
- package/dist/shim/storage/index.js +64 -8
- package/dist/shim/storage/make-namespace-store.js +5 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/dist/shim/bundle/frqnb.js +0 -112
- package/dist/shim/bundle/manifest.js +0 -95
- package/dist/shim/storage/modules.js +0 -249
- package/dist/shim/storage/projects.js +0 -150
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* lib/shim/bundle/indexHtml.js — the
|
|
5
|
-
*
|
|
4
|
+
* lib/shim/bundle/indexHtml.js — the generated `index.html` of an exported project folder: the HOST PAGE,
|
|
5
|
+
* and the ONE writer + ONE parser of the boot bag it carries.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the page's import map carries those rows and the scene doors' row, rendered by the ONE table
|
|
11
|
-
* (`pageImportMapFor(<the pinned dist root>/)`, lib/shim/project/specifiers.js), so the sketch resolves
|
|
12
|
-
* to the SAME module URLs the boot loads. The pinned dist root is the router's one spelling
|
|
13
|
-
* (`cdnDistBaseFor`); the `@<pin>` is the passed-in `sdkVersion` (DERIVED at export time — never a
|
|
14
|
-
* literal here).
|
|
7
|
+
* buildIndexHtml({ name, sdkVersion, renderState }) → the page text
|
|
8
|
+
* readBootBag(html) → the renderState it carries (the import's one reader)
|
|
9
|
+
* BOOT_BAG_ID 'frqtl-boot' — the bag's element id
|
|
15
10
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* The page boots the folder it sits in as a SERVED PROJECT: `bootProject(mount, { url: './' }, { renderState })`
|
|
12
|
+
* from `@descix/frqtl-web@<pin>` on the CDN. `frqtl.json` names the entry (P2a: the linker reads it first), so
|
|
13
|
+
* the page names no file. The import map is the ONE table's (`pageImportMapFor(<the pinned dist root>/)`,
|
|
14
|
+
* lib/shim/project/specifiers.js), so a sketch's `@descix/frqtl-web/...` imports resolve to the same module URLs
|
|
15
|
+
* the boot loads. The pinned dist root is the router's one spelling (`cdnDistBaseFor`); the pin is the passed-in
|
|
16
|
+
* `sdkVersion` (derived at export time, never a literal here).
|
|
17
|
+
*
|
|
18
|
+
* The boot bag — the host's renderState, which never enters frqtl.json or the project identity — is embedded as
|
|
19
|
+
* `<script type="application/json" id="frqtl-boot">`, `<` escaped so no payload closes the element. The page reads
|
|
20
|
+
* it from that element; an Import reads it back with readBootBag and drops the page. A page opened via `file://`
|
|
21
|
+
* cannot fetch the folder: it says to serve the folder over http.
|
|
19
22
|
*
|
|
20
23
|
* Layer 2 (physics): none — pure host projection.
|
|
21
24
|
*/
|
|
@@ -25,41 +28,38 @@
|
|
|
25
28
|
import { pageImportMapFor, FRQTL_WEB_BOOT } from '../project/specifiers.js';
|
|
26
29
|
import { cdnDistBaseFor } from '../engine/router.js';
|
|
27
30
|
|
|
31
|
+
/** The boot bag's element id. */
|
|
32
|
+
export var BOOT_BAG_ID = 'frqtl-boot';
|
|
33
|
+
|
|
34
|
+
var _BAG_OPEN = '<script type="application/json" id="' + BOOT_BAG_ID + '">';
|
|
35
|
+
var _BAG_CLOSE = '</script>';
|
|
36
|
+
|
|
28
37
|
function _escapeHtml(s) {
|
|
29
38
|
return String(s == null ? '' : s)
|
|
30
39
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
/** Safe inline
|
|
42
|
+
/** Safe inline JSON: `<` escaped so a `</script>`-like payload cannot break out. */
|
|
34
43
|
function _inlineJson(obj) {
|
|
35
44
|
return JSON.stringify(obj == null ? null : obj).replace(/</g, '\\u003c');
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
39
|
-
* Build the standalone `index.html` for a recipe bundle.
|
|
40
|
-
*
|
|
41
48
|
* @param {object} args
|
|
42
|
-
* @param {string} args.name — project name (page title)
|
|
43
|
-
* @param {string} args.sdkVersion — the `@descix/frqtl-web@<pin>` version (REQUIRED)
|
|
44
|
-
* @param {object}
|
|
45
|
-
*
|
|
46
|
-
* @param {string[]} [args.scripts] — the v4 envelope's setup-factory delta (inlined as JSON): nothing
|
|
47
|
-
* classic is left to load, so the page REFUSES a non-empty list at boot,
|
|
48
|
-
* naming the fix (import the factory in the sketch).
|
|
49
|
-
* @param {string} [args.recipeFile='experiment.js'] — the project's entry file.
|
|
50
|
-
* @returns {string} the complete `index.html`.
|
|
49
|
+
* @param {string} args.name — the project's name (frqtl.json `name`; the page title)
|
|
50
|
+
* @param {string} args.sdkVersion — the `@descix/frqtl-web@<pin>` version (REQUIRED)
|
|
51
|
+
* @param {object|null} args.renderState — the normalized boot bag (null boots with the page's defaults)
|
|
52
|
+
* @returns {string} the complete `index.html`
|
|
51
53
|
*/
|
|
52
54
|
export function buildIndexHtml(args) {
|
|
53
55
|
args = args || {};
|
|
54
56
|
if (typeof args.sdkVersion !== 'string' || args.sdkVersion.length === 0) {
|
|
55
|
-
throw new Error('[bundle/indexHtml] buildIndexHtml: args.sdkVersion is REQUIRED (the CDN pin).');
|
|
57
|
+
throw new Error('[bundle/indexHtml] buildIndexHtml: args.sdkVersion is REQUIRED (the CDN pin). (FAIL-LOUD.)');
|
|
58
|
+
}
|
|
59
|
+
if (typeof args.name !== 'string' || args.name.length === 0) {
|
|
60
|
+
throw new Error('[bundle/indexHtml] buildIndexHtml: args.name is the project\'s frqtl.json name. (FAIL-LOUD.)');
|
|
56
61
|
}
|
|
57
|
-
var name = (typeof args.name === 'string' && args.name.length > 0) ? args.name : 'FRQTL Recipe';
|
|
58
|
-
var recipeFile = (typeof args.recipeFile === 'string' && args.recipeFile.length > 0)
|
|
59
|
-
? args.recipeFile : 'experiment.js';
|
|
60
62
|
var importMapJson = _inlineJson(pageImportMapFor(cdnDistBaseFor(args.sdkVersion) + '/'));
|
|
61
|
-
var renderStateJson = _inlineJson(args.renderState || null);
|
|
62
|
-
var scriptsJson = _inlineJson(Array.isArray(args.scripts) ? args.scripts : []);
|
|
63
63
|
|
|
64
64
|
return [
|
|
65
65
|
'<!DOCTYPE html>',
|
|
@@ -67,7 +67,7 @@ export function buildIndexHtml(args) {
|
|
|
67
67
|
'<head>',
|
|
68
68
|
' <meta charset="UTF-8">',
|
|
69
69
|
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
70
|
-
' <title>' + _escapeHtml(name) + ' — FRQTL</title>',
|
|
70
|
+
' <title>' + _escapeHtml(args.name) + ' — FRQTL</title>',
|
|
71
71
|
' <style>',
|
|
72
72
|
' html,body{margin:0;height:100%;background:#0b0b0f;color:#ddd;',
|
|
73
73
|
' font:14px/1.5 ui-sans-serif,system-ui,sans-serif;}',
|
|
@@ -80,23 +80,21 @@ export function buildIndexHtml(args) {
|
|
|
80
80
|
'<body>',
|
|
81
81
|
' <div id="frqtl-mount"></div>',
|
|
82
82
|
' <pre id="frqtl-err"></pre>',
|
|
83
|
+
' ' + _BAG_OPEN + _inlineJson(args.renderState) + _BAG_CLOSE,
|
|
83
84
|
' <script type="importmap">' + importMapJson + '</script>',
|
|
84
85
|
' <script type="module">',
|
|
85
86
|
' import { bootProject } from ' + JSON.stringify(FRQTL_WEB_BOOT.specifier) + ';',
|
|
86
|
-
' const
|
|
87
|
-
' const SCRIPTS = ' + scriptsJson + ';',
|
|
87
|
+
' const renderState = JSON.parse(document.getElementById(' + JSON.stringify(BOOT_BAG_ID) + ').textContent);',
|
|
88
88
|
' const mount = document.getElementById("frqtl-mount");',
|
|
89
89
|
' const errBox = document.getElementById("frqtl-err");',
|
|
90
90
|
' function fail(title, detail){',
|
|
91
91
|
' errBox.style.display = "block";',
|
|
92
92
|
' errBox.textContent = title + "\\n\\n" + detail;',
|
|
93
93
|
' }',
|
|
94
|
-
' if (
|
|
95
|
-
' fail("
|
|
96
|
-
' } else if (location.protocol === "file:") {',
|
|
97
|
-
' fail("Serve this folder over http", "e.g. `npx serve` — a file:// page cannot fetch ' + _escapeHtml(recipeFile) + '.");',
|
|
94
|
+
' if (location.protocol === "file:") {',
|
|
95
|
+
' fail("Serve this folder over http", "e.g. `npx serve` — a file:// page cannot fetch the project files.");',
|
|
98
96
|
' } else {',
|
|
99
|
-
' bootProject(mount, { url: new URL("./", location.href).href
|
|
97
|
+
' bootProject(mount, { url: new URL("./", location.href).href }, { renderState: renderState })',
|
|
100
98
|
' .catch((e) => fail("FRQTL project boot failed", String(e && e.stack || e)));',
|
|
101
99
|
' }',
|
|
102
100
|
' </script>',
|
|
@@ -105,3 +103,32 @@ export function buildIndexHtml(args) {
|
|
|
105
103
|
''
|
|
106
104
|
].join('\n');
|
|
107
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* THE parser of the boot bag: the renderState an exported page carries (null when it carries none).
|
|
109
|
+
* THROWS when the page holds no bag element, or more than one, or one that is not JSON.
|
|
110
|
+
* @param {string} html
|
|
111
|
+
* @returns {object|null}
|
|
112
|
+
*/
|
|
113
|
+
export function readBootBag(html) {
|
|
114
|
+
if (typeof html !== 'string') {
|
|
115
|
+
throw new Error('[bundle/indexHtml] readBootBag(html): the page text (got ' + typeof html + '). (FAIL-LOUD.)');
|
|
116
|
+
}
|
|
117
|
+
var at = html.indexOf(_BAG_OPEN);
|
|
118
|
+
if (at === -1) {
|
|
119
|
+
throw new Error('[bundle/indexHtml] the index.html carries no boot bag (<script type="application/json" id="' +
|
|
120
|
+
BOOT_BAG_ID + '">) — it is not the page an Export writes. (FAIL-LOUD.)');
|
|
121
|
+
}
|
|
122
|
+
if (html.indexOf(_BAG_OPEN, at + 1) !== -1) {
|
|
123
|
+
throw new Error('[bundle/indexHtml] the index.html carries two boot bags — an Export writes one. (FAIL-LOUD.)');
|
|
124
|
+
}
|
|
125
|
+
var start = at + _BAG_OPEN.length;
|
|
126
|
+
var end = html.indexOf(_BAG_CLOSE, start);
|
|
127
|
+
if (end === -1) {
|
|
128
|
+
throw new Error('[bundle/indexHtml] the boot bag element is not closed. (FAIL-LOUD.)');
|
|
129
|
+
}
|
|
130
|
+
try { return JSON.parse(html.slice(start, end)); }
|
|
131
|
+
catch (e) {
|
|
132
|
+
throw new Error('[bundle/indexHtml] the boot bag is not valid JSON: ' + ((e && e.message) || e) + '. (FAIL-LOUD.)');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -148,7 +148,7 @@ var DIST_PROFILE = buildDistProfile('../..');
|
|
|
148
148
|
// lib/shim/engine/test/router.test.mjs asserts them against the two
|
|
149
149
|
// package.json#version fields so a forgotten bump fails `make verify`, not a
|
|
150
150
|
// silent stale-CDN-URL bug.
|
|
151
|
-
var CDN_SDK_VERSION = '0.6.0-
|
|
151
|
+
var CDN_SDK_VERSION = '0.6.0-p4.1'; // sdk/frqtl-web/package.json#version (the ONE version owner) — bound by router.test.mjs
|
|
152
152
|
var CDN_MATH_SDK_VERSION = '0.3.2'; // sdk/egpt-math-sdk/package.json#version — bound by router.test.mjs; bumped with each npm publish
|
|
153
153
|
|
|
154
154
|
/**
|
|
@@ -507,7 +507,7 @@ var _assertProfileParity = (function () {
|
|
|
507
507
|
* // CDN mode (static hosting — the artifact host shell has no server, no page anchor):
|
|
508
508
|
* const cdnRouter = engineRouter({ mode: 'cdn', engine: 'wasm' });
|
|
509
509
|
* console.log(cdnRouter.resolve('compat/engine.js'));
|
|
510
|
-
* // 'https://cdn.jsdelivr.net/npm/@descix/frqtl-web@0.6.0-
|
|
510
|
+
* // 'https://cdn.jsdelivr.net/npm/@descix/frqtl-web@0.6.0-p4.1/dist/frqtl/compat/engine.js'
|
|
511
511
|
*
|
|
512
512
|
* // CDN mode, self-hosted: a copy of dist/ served from the consumer's own origin:
|
|
513
513
|
* const selfHosted = engineRouter({ mode: 'cdn', engine: 'wasm', base: 'https://example.org/vendor/frqtl-web/dist' });
|
package/dist/shim/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { WALKTHROUGH, walkthroughIs3D, walkthroughModes, walkthroughGroup,
|
|
|
22
22
|
walkthroughById,
|
|
23
23
|
FIRST_CONTACT_WALKTHROUGH_ID,
|
|
24
24
|
defaultWalkthroughModeId, effectiveRenderState } from './registry/walkthrough.js';
|
|
25
|
-
export {
|
|
25
|
+
export { projectOf, scenePath, EMPTY_SCENE } from './registry/scene-source.js';
|
|
26
26
|
// The host-side experiment-iframe guard (the false-200 trap). Exported from the
|
|
27
27
|
// barrel so every host that frames an experiment page (lib/features/ide's GYM)
|
|
28
28
|
// asserts against the SAME contract.
|
|
@@ -99,7 +99,7 @@ export const PROSE_CELL_TYPES = Object.freeze(['markdown']);
|
|
|
99
99
|
* e.g. `setupDoubleSlit`). Absent → `'factory'`, so every existing notebook is
|
|
100
100
|
* byte-identical.
|
|
101
101
|
* - `'recipe'`: the module's DEFAULT export is a `buildExperiment(exp)` RECIPE (a single
|
|
102
|
-
* RecipeContext arg — the sketch form
|
|
102
|
+
* RecipeContext arg — the sketch form the scene files
|
|
103
103
|
* write). The shim builds a
|
|
104
104
|
* `frqtl.RecipeContext` and runs `exp.build(buildExperiment)` (the canonical
|
|
105
105
|
* co-init call + `addObject()` recipe path), NOT the factory signature.
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Thin CRUD helpers that persist a serialized <frqtl-notebook> document on top
|
|
7
7
|
* of any StorageProvider (lib/shim/storage/index.js) using the "notebooks"
|
|
8
|
-
* namespace
|
|
9
|
-
* lib/shim/storage/
|
|
10
|
-
* projects, Studio modules, and Notebooks.
|
|
8
|
+
* namespace, through the shared namespace-CRUD factory
|
|
9
|
+
* (lib/shim/storage/make-namespace-store.js).
|
|
11
10
|
*
|
|
12
11
|
* ── Why this lives in lib/shim ──────────────────────────────────────────────
|
|
13
12
|
*
|
|
@@ -5,17 +5,30 @@
|
|
|
5
5
|
* project identity, re-exported from their owners (this file owns nothing).
|
|
6
6
|
*
|
|
7
7
|
* FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor,
|
|
8
|
-
* pageImportMapFor
|
|
8
|
+
* pageImportMapFor, FRQTL_WEB_HOST_SPECIFIERS specifiers.js (the host rows: a host page reads the
|
|
9
|
+
* dist root from its own map through them)
|
|
9
10
|
* linkProject, sketchProject, SKETCH_ENTRY link.js
|
|
10
11
|
* projectIdentity identity.js
|
|
12
|
+
* MANIFEST_FILE, MANIFEST_KEYS, normalizeManifest,
|
|
13
|
+
* parseManifest, serializeManifest, manifestFor manifest.js (THE owner of frqtl.json)
|
|
14
|
+
* RECORD_FILES, RECORD_KEYS, CHANGE_KEYS, OPS, BY,
|
|
15
|
+
* CLIP_SECONDS, CLIP_NAME, clipName, jsonText,
|
|
16
|
+
* recordFolderFaults record-format.js (THE owner of the Record folder's shape)
|
|
11
17
|
*
|
|
12
18
|
* PROJECT_EXPORTS is this entry's export list; a conformance test holds the module's exports equal to
|
|
13
19
|
* it, and the package gate holds it equal across @descix/frqtl-web and @descix/frqtl-node.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
export const PROJECT_EXPORTS = Object.freeze(['FRQTL_WEB_SPECIFIERS', 'libPathOf', 'importMapFor', 'pageImportMapFor',
|
|
17
|
-
'
|
|
23
|
+
'FRQTL_WEB_HOST_SPECIFIERS',
|
|
24
|
+
'linkProject', 'sketchProject', 'SKETCH_ENTRY', 'projectIdentity',
|
|
25
|
+
'MANIFEST_FILE', 'MANIFEST_KEYS', 'normalizeManifest', 'parseManifest', 'serializeManifest', 'manifestFor',
|
|
26
|
+
'RECORD_FILES', 'RECORD_KEYS', 'CHANGE_KEYS', 'OPS', 'BY', 'CLIP_SECONDS', 'CLIP_NAME', 'clipName', 'jsonText',
|
|
27
|
+
'recordFolderFaults']);
|
|
18
28
|
|
|
19
|
-
export { FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor, pageImportMapFor } from './specifiers.js';
|
|
29
|
+
export { FRQTL_WEB_SPECIFIERS, libPathOf, importMapFor, pageImportMapFor, FRQTL_WEB_HOST_SPECIFIERS } from './specifiers.js';
|
|
20
30
|
export { linkProject, sketchProject, SKETCH_ENTRY } from './link.js';
|
|
21
31
|
export { projectIdentity } from './identity.js';
|
|
32
|
+
export { MANIFEST_FILE, MANIFEST_KEYS, normalizeManifest, parseManifest, serializeManifest, manifestFor } from './manifest.js';
|
|
33
|
+
export { RECORD_FILES, RECORD_KEYS, CHANGE_KEYS, OPS, BY, CLIP_SECONDS, CLIP_NAME, clipName, jsonText,
|
|
34
|
+
recordFolderFaults } from './record-format.js';
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* link.js — THE link site: a project (
|
|
5
|
-
*
|
|
4
|
+
* link.js — THE link site: a project (a folder of files held as text, or a served folder) becomes imported
|
|
5
|
+
* modules, and its entry's default export is the scene.
|
|
6
6
|
*
|
|
7
|
-
* linkProject(project) → Promise<{ buildExperiment, files, urls, project }>
|
|
8
|
-
* project = { files: { 'sketch.js': text, 'parts/X.js': text, … }, entry
|
|
9
|
-
* | { url: '<served folder>/', entry
|
|
7
|
+
* linkProject(project) → Promise<{ buildExperiment, module, manifest, files, urls, project }> THE result contract
|
|
8
|
+
* project = { files: { 'frqtl.json': text, 'sketch.js': text, 'parts/X.js': text, 'README.md': text, … }, entry?, mint, bare? }
|
|
9
|
+
* | { url: '<served folder>/', entry?, mint, bare? }
|
|
10
10
|
* url absolute, or relative to the document's base URL in a page ('./'); ends in "/"
|
|
11
|
-
* entry
|
|
12
|
-
*
|
|
11
|
+
* entry the root-relative path of the module whose default export is `function buildExperiment(exp)`.
|
|
12
|
+
* It comes from frqtl.json (manifest.js, the one owner) when the project carries one; an `entry`
|
|
13
|
+
* given beside a manifest must EQUAL manifest.entry, or the link REJECTS naming both. With neither
|
|
14
|
+
* a manifest nor an entry the link REJECTS naming frqtl.json. A served folder's
|
|
15
|
+
* `<url>frqtl.json` is fetched first; a 404 there is allowed only when `entry` is given (then
|
|
16
|
+
* there is no manifest).
|
|
13
17
|
* mint REQUIRED — the host's (text, path) → { url, release? }: `blobMint` in a page, `dataMint` in
|
|
14
18
|
* node. Hosts differ ONLY here. A served folder is minted too: each file is fetched ONCE, as
|
|
15
19
|
* bytes; those bytes are the identity, and the same bytes, decoded once (UTF-8, fatal), are
|
|
@@ -18,24 +22,37 @@
|
|
|
18
22
|
* lib-identity path (specifiers.js libPathOf) under THIS copy's package root
|
|
19
23
|
* (compat/package-root.js), so a project links against the very modules its boot loaded.
|
|
20
24
|
* A host whose modules are not served one file per URL (frqtl-web.min.js) passes its own.
|
|
21
|
-
* resolves
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* resolves
|
|
26
|
+
* buildExperiment the entry's default export
|
|
27
|
+
* module the entry module's namespace (its `parts` block is `module.parts`)
|
|
28
|
+
* manifest frqtl.json's normalized bag (manifest.js normalizeManifest), or null when the project
|
|
29
|
+
* was linked by `entry` alone
|
|
30
|
+
* files THE LINKED SET's bytes by path — frqtl.json and every module file (linkedFiles): fetched,
|
|
31
|
+
* for a served folder; the UTF-8 encoding of each text, for a file map. The input of
|
|
32
|
+
* projectIdentity (identity.js).
|
|
33
|
+
* urls each module's minted URL by path (a forked part registers its kind `from` that URL —
|
|
34
|
+
* lib/shim/project/parts-module.js maps it back to the path)
|
|
35
|
+
* project the linked project as TEXT — { files: { path: text }, entry, mint, bare? } — what a facade
|
|
36
|
+
* holds and re-links (RecipeContext.build(linked)). For a file map its files are EVERY file
|
|
37
|
+
* given (frqtl.json and README.md included), so a facade's sketch is the whole folder; for a
|
|
38
|
+
* served folder they are the files fetched, decoded.
|
|
27
39
|
*
|
|
28
|
-
*
|
|
40
|
+
* THE LINKED SET (linkedFiles(files) → the sub-map): frqtl.json plus every MODULE file — a path ending ".js" or
|
|
41
|
+
* ".mjs" — a pure path function of the folder, so every host hashes one set. Each module file must be reached
|
|
42
|
+
* from the entry; any other file (README.md, notes.txt) travels with the folder, unlinked and unhashed.
|
|
43
|
+
*
|
|
44
|
+
* In a project, a module imports another project module by a RELATIVE specifier, and the package by
|
|
29
45
|
* `@descix/frqtl-web/...`; nothing else. Relative specifiers are rewritten to the minted URLs of the files
|
|
30
46
|
* they name, in topological order (a file is minted after everything it imports). REFUSED before anything
|
|
31
47
|
* is minted, each naming the file, the specifier and the reason: a relative specifier that climbs out of
|
|
32
|
-
* the root (package internals have no public specifier), one that names
|
|
48
|
+
* the root (package internals have no public specifier), one that names a non-module path (not ".js" /
|
|
49
|
+
* ".mjs": a data file, an extensionless name, frqtl.json), one that names no project file (never an HTTP
|
|
33
50
|
* sibling), a @descix/frqtl-web specifier that is not a row of FRQTL_WEB_SPECIFIERS, any other specifier;
|
|
34
|
-
* an import cycle among project files; a file
|
|
35
|
-
*
|
|
36
|
-
*
|
|
51
|
+
* an import cycle among project files; a module file the entry never reaches; an invalid frqtl.json; a
|
|
52
|
+
* served file that is not UTF-8; a missing or non-function default export of the entry, a missing entry,
|
|
53
|
+
* an empty project. Every argument error REJECTS.
|
|
37
54
|
*
|
|
38
|
-
* blobMint(text) → { url, release() } a `blob:` module URL (a page)
|
|
55
|
+
* blobMint(text) → { url, release() } a `blob:` module URL (a page), one per byte-identical text
|
|
39
56
|
* dataMint(text) → { url, release() } a `data:` module URL (node)
|
|
40
57
|
*
|
|
41
58
|
* A one-string sketch is the one-file project: `{ files: { 'sketch.js': text }, entry: 'sketch.js' }` —
|
|
@@ -44,6 +61,7 @@
|
|
|
44
61
|
|
|
45
62
|
import { libPathOf, FRQTL_WEB_PACKAGE, FRQTL_WEB_SPECIFIERS } from './specifiers.js';
|
|
46
63
|
import { checkProjectPath, projectBytes } from './identity.js';
|
|
64
|
+
import { MANIFEST_FILE, parseManifest } from './manifest.js';
|
|
47
65
|
import { packageRoot } from '../../frqtl/compat/package-root.js';
|
|
48
66
|
|
|
49
67
|
const SKETCH_FORM = 'export default function buildExperiment(exp) { … }';
|
|
@@ -169,6 +187,11 @@ async function _walk(entry, sourceOf) {
|
|
|
169
187
|
_fail('"' + p + '" imports "' + imp.spec + '" — outside the project; package internals have no ' +
|
|
170
188
|
'public specifier (a project file imports the package by a @descix/frqtl-web/... row).');
|
|
171
189
|
}
|
|
190
|
+
if (!isModulePath(target)) {
|
|
191
|
+
_fail('"' + p + '" imports "' + imp.spec + '" (' + target + '), which is not a module path — a project ' +
|
|
192
|
+
'imports only its module files (' + MODULE_EXTENSIONS.join(' / ') + '); any other file of the folder ' +
|
|
193
|
+
'(a data file, an extensionless name, ' + MANIFEST_FILE + ') travels with it unlinked.');
|
|
194
|
+
}
|
|
172
195
|
list.push(Object.assign({ kind: 'file', target: target }, imp));
|
|
173
196
|
} else if (_isPackage(imp.spec)) {
|
|
174
197
|
if (!_isPublished(imp.spec)) {
|
|
@@ -218,17 +241,46 @@ function _rewrite(text, list, urlOf) {
|
|
|
218
241
|
return out;
|
|
219
242
|
}
|
|
220
243
|
|
|
244
|
+
/** The extensions of a project MODULE file; every other path of a folder is a non-module file. */
|
|
245
|
+
const MODULE_EXTENSIONS = Object.freeze(['.js', '.mjs']);
|
|
246
|
+
|
|
247
|
+
/** A project path names a module file (".js" / ".mjs"). */
|
|
248
|
+
function isModulePath(p) {
|
|
249
|
+
return MODULE_EXTENSIONS.some(function (x) { return p.endsWith(x); });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* THE LINKED SET of a folder: the sub-map of `files` holding frqtl.json and every module file (".js" /
|
|
254
|
+
* ".mjs"), values as given (text or bytes). A pure function of the paths, so every host — a page door,
|
|
255
|
+
* hosted eyes in node — hashes one set; the link refuses a folder whose module files the entry does not
|
|
256
|
+
* all reach, so for a folder that links this IS the set linked. Any other file passes through unhashed.
|
|
257
|
+
* @param {Object<string, *>} files
|
|
258
|
+
* @returns {Object<string, *>}
|
|
259
|
+
*/
|
|
260
|
+
export function linkedFiles(files) {
|
|
261
|
+
if (!files || typeof files !== 'object') {
|
|
262
|
+
_fail('linkedFiles(files): files is the map { path: text | bytes } (got ' + (files === null ? 'null' : typeof files) + ').');
|
|
263
|
+
}
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const p of Object.keys(files)) {
|
|
266
|
+
if (p === MANIFEST_FILE || isModulePath(p)) { out[p] = files[p]; }
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
221
271
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* reach, naming it.
|
|
272
|
+
* Every module file of a file map is reached from the entry (a project's modules are exactly the graph its
|
|
273
|
+
* entry imports, so linkedFiles is the set linked). REFUSES an unreached one, naming it.
|
|
225
274
|
*/
|
|
226
275
|
function _refuseUnreached(project, g) {
|
|
227
276
|
if (project.files === undefined) { return; }
|
|
228
|
-
const unused = Object.keys(project.files).filter(function (p) {
|
|
277
|
+
const unused = Object.keys(linkedFiles(project.files)).filter(function (p) {
|
|
278
|
+
return p !== MANIFEST_FILE && !Object.prototype.hasOwnProperty.call(g.files, p);
|
|
279
|
+
});
|
|
229
280
|
if (unused.length) {
|
|
230
|
-
_fail('unused project file "' + unused[0] + '" — a project holds the files its entry imports' +
|
|
231
|
-
(unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '
|
|
281
|
+
_fail('unused project file "' + unused[0] + '" — a project holds the module files its entry imports' +
|
|
282
|
+
(unused.length > 1 ? ' (also unused: ' + unused.slice(1).join(', ') + ')' : '') + '; a non-module file ' +
|
|
283
|
+
'(README.md) travels with the folder unlinked.');
|
|
232
284
|
}
|
|
233
285
|
}
|
|
234
286
|
|
|
@@ -244,26 +296,84 @@ function _entryFunction(ns, entry) {
|
|
|
244
296
|
return ns.default;
|
|
245
297
|
}
|
|
246
298
|
|
|
299
|
+
/** The served folder's frqtl.json: { manifest, text, bytes }, or null on a 404. One fetch. */
|
|
300
|
+
async function _fetchManifest(base) {
|
|
301
|
+
const u = new URL(MANIFEST_FILE, base).href;
|
|
302
|
+
let res;
|
|
303
|
+
try { res = await fetch(u); }
|
|
304
|
+
catch (e) { _fail('fetching "' + MANIFEST_FILE + '" (' + u + ') failed: ' + String((e && e.message) || e) + '.'); }
|
|
305
|
+
if (res.status === 404) { return null; }
|
|
306
|
+
if (!res.ok) { _fail('"' + MANIFEST_FILE + '" is not served at ' + u + ' (HTTP ' + res.status + ').'); }
|
|
307
|
+
const raw = new Uint8Array(await res.arrayBuffer());
|
|
308
|
+
let text;
|
|
309
|
+
try { text = _UTF8.decode(raw); }
|
|
310
|
+
catch (e) { _fail('"' + MANIFEST_FILE + '" is not valid UTF-8 (' + u + ').'); }
|
|
311
|
+
let manifest;
|
|
312
|
+
try { manifest = parseManifest(text); }
|
|
313
|
+
catch (e) {
|
|
314
|
+
const type = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : null;
|
|
315
|
+
_fail('the served ' + MANIFEST_FILE + ' (' + u + ', HTTP ' + res.status + (type ? ', ' + type : '') + ') is refused: ' +
|
|
316
|
+
String((e && e.message) || e) + ' — a host that answers a missing file with a page serves no 404, so a folder ' +
|
|
317
|
+
'there carries its own ' + MANIFEST_FILE + '.');
|
|
318
|
+
}
|
|
319
|
+
return { manifest: manifest, text: text, bytes: raw };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The entry: frqtl.json's when the project carries one (a given `entry` must equal it), else the given one.
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function _entryOf(given, manifest) {
|
|
327
|
+
if (manifest) {
|
|
328
|
+
if (given !== undefined && given !== manifest.entry) {
|
|
329
|
+
_fail('entryOf: `entry` "' + given + '" disagrees with ' + MANIFEST_FILE + '\'s "entry" "' + manifest.entry +
|
|
330
|
+
'" — the manifest names the entry; drop `entry`, or make the two equal.');
|
|
331
|
+
}
|
|
332
|
+
return manifest.entry;
|
|
333
|
+
}
|
|
334
|
+
if (given === undefined) {
|
|
335
|
+
_fail('entryOf: no entry — the project carries no ' + MANIFEST_FILE + ' (manifest.js; manifestFor({ name, entry }) ' +
|
|
336
|
+
'writes one) and no `entry` was given (a one-string sketch: { files: { "sketch.js": text }, entry: "sketch.js" }).');
|
|
337
|
+
}
|
|
338
|
+
return given;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* entryOf(files, given) → the project's entry path — THE entry rule (every host asks it, never a copy): the entry
|
|
343
|
+
* frqtl.json names (read through its owner, parseManifest) when `files` carries one, and a `given` entry must equal
|
|
344
|
+
* it; else `given`. THROWS naming the rule: a disagreeing entry, no frqtl.json and no entry, an invalid frqtl.json.
|
|
345
|
+
* Not on PROJECT_EXPORTS (like linkedFiles).
|
|
346
|
+
* @param {Object<string, string>} files
|
|
347
|
+
* @param {string} [given]
|
|
348
|
+
* @returns {string}
|
|
349
|
+
*/
|
|
350
|
+
export function entryOf(files, given) {
|
|
351
|
+
const has = files && Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE);
|
|
352
|
+
return _entryOf(given, has ? parseManifest(files[MANIFEST_FILE]) : null);
|
|
353
|
+
}
|
|
354
|
+
|
|
247
355
|
/**
|
|
248
356
|
* @param {object} project — see the file header
|
|
249
|
-
* @returns {Promise<{buildExperiment: function,
|
|
250
|
-
* project: {files: Object<string, string>, entry: string, mint: function, bare?: function}}>}
|
|
357
|
+
* @returns {Promise<{buildExperiment: function, module: object, manifest: ?object, files: Object<string, Uint8Array>,
|
|
358
|
+
* urls: Object<string, string>, project: {files: Object<string, string>, entry: string, mint: function, bare?: function}}>}
|
|
251
359
|
*/
|
|
252
360
|
export async function linkProject(project) {
|
|
253
361
|
if (!project || typeof project !== 'object') {
|
|
254
|
-
_fail('linkProject(project): project is { files,
|
|
362
|
+
_fail('linkProject(project): project is { files, mint } or { url, mint } (+ `entry` when it carries no ' +
|
|
363
|
+
MANIFEST_FILE + ') (got ' + typeof project + ').');
|
|
255
364
|
}
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
'
|
|
365
|
+
const given = project.entry;
|
|
366
|
+
if (given !== undefined) {
|
|
367
|
+
if (typeof given !== 'string' || !given) {
|
|
368
|
+
_fail('linkProject: `entry` is the root-relative path of the module whose default export is the scene (got ' +
|
|
369
|
+
JSON.stringify(given) + ').');
|
|
370
|
+
}
|
|
371
|
+
checkProjectPath(given);
|
|
260
372
|
}
|
|
261
|
-
checkProjectPath(entry);
|
|
262
373
|
const hasFiles = project.files !== undefined;
|
|
263
374
|
const hasUrl = project.url !== undefined;
|
|
264
375
|
if (hasFiles === hasUrl) {
|
|
265
|
-
_fail('linkProject: a project is EITHER { files
|
|
266
|
-
(hasFiles ? 'both' : 'neither') + ').');
|
|
376
|
+
_fail('linkProject: a project is EITHER { files } OR { url } (got ' + (hasFiles ? 'both' : 'neither') + ').');
|
|
267
377
|
}
|
|
268
378
|
const mint = project.mint;
|
|
269
379
|
if (typeof mint !== 'function') {
|
|
@@ -276,7 +386,8 @@ export async function linkProject(project) {
|
|
|
276
386
|
}
|
|
277
387
|
|
|
278
388
|
let sourceOf;
|
|
279
|
-
let
|
|
389
|
+
let found = null; // frqtl.json: { manifest, text, bytes }
|
|
390
|
+
let encoded = null;
|
|
280
391
|
if (hasFiles) {
|
|
281
392
|
const files = project.files;
|
|
282
393
|
if (!files || typeof files !== 'object' || Object.keys(files).length === 0) {
|
|
@@ -286,7 +397,10 @@ export async function linkProject(project) {
|
|
|
286
397
|
checkProjectPath(p);
|
|
287
398
|
if (typeof files[p] !== 'string') { _fail('linkProject: file "' + p + '" is not text.'); }
|
|
288
399
|
}
|
|
289
|
-
|
|
400
|
+
encoded = projectBytes(files);
|
|
401
|
+
if (Object.prototype.hasOwnProperty.call(files, MANIFEST_FILE)) {
|
|
402
|
+
found = { manifest: parseManifest(files[MANIFEST_FILE]), text: files[MANIFEST_FILE], bytes: encoded[MANIFEST_FILE] };
|
|
403
|
+
}
|
|
290
404
|
sourceOf = async function (p, from, spec) {
|
|
291
405
|
if (!Object.prototype.hasOwnProperty.call(files, p)) {
|
|
292
406
|
_fail(from === undefined
|
|
@@ -303,7 +417,8 @@ export async function linkProject(project) {
|
|
|
303
417
|
if (typeof project.url !== 'string' || !project.url.endsWith('/')) {
|
|
304
418
|
_fail('linkProject({ url }): url is the served project folder, ending in "/" (got ' + JSON.stringify(project.url) + ').');
|
|
305
419
|
}
|
|
306
|
-
base = _folderUrl(project.url);
|
|
420
|
+
const base = _folderUrl(project.url);
|
|
421
|
+
found = await _fetchManifest(base);
|
|
307
422
|
sourceOf = async function (p, from, spec) {
|
|
308
423
|
const u = new URL(p, base).href;
|
|
309
424
|
let res;
|
|
@@ -322,10 +437,20 @@ export async function linkProject(project) {
|
|
|
322
437
|
return { text: text, bytes: raw };
|
|
323
438
|
};
|
|
324
439
|
}
|
|
440
|
+
const manifest = found ? found.manifest : null;
|
|
441
|
+
const entry = _entryOf(given, manifest);
|
|
442
|
+
if (!isModulePath(entry)) {
|
|
443
|
+
_fail('linkProject: the entry "' + entry + '" is not a module path (' + MODULE_EXTENSIONS.join(' / ') + ').');
|
|
444
|
+
}
|
|
325
445
|
|
|
326
446
|
const g = await _walk(entry, sourceOf);
|
|
327
447
|
_refuseUnreached(project, g);
|
|
328
448
|
|
|
449
|
+
// The linked set's bytes: a file map's through linkedFiles (its owner); a served folder's are what was fetched.
|
|
450
|
+
const bytes = hasFiles ? linkedFiles(encoded) : linkedFiles(Object.assign(found ? { [MANIFEST_FILE]: found.bytes } : {}, g.bytes));
|
|
451
|
+
const heldFiles = hasFiles ? Object.assign({}, project.files)
|
|
452
|
+
: Object.assign(found ? { [MANIFEST_FILE]: found.text } : {}, g.files);
|
|
453
|
+
|
|
329
454
|
const urls = {};
|
|
330
455
|
const minted = [];
|
|
331
456
|
try {
|
|
@@ -339,9 +464,10 @@ export async function linkProject(project) {
|
|
|
339
464
|
urls[p] = m.url;
|
|
340
465
|
}
|
|
341
466
|
const ns = await import(urls[entry]);
|
|
342
|
-
const held = { files:
|
|
467
|
+
const held = { files: heldFiles, entry: entry, mint: mint };
|
|
343
468
|
if (project.bare !== undefined) { held.bare = project.bare; }
|
|
344
|
-
return { buildExperiment: _entryFunction(ns, entry),
|
|
469
|
+
return { buildExperiment: _entryFunction(ns, entry), module: ns, manifest: manifest, files: bytes,
|
|
470
|
+
urls: Object.assign({}, urls), project: held };
|
|
345
471
|
} finally {
|
|
346
472
|
for (const m of minted) { if (typeof m.release === 'function') { m.release(); } }
|
|
347
473
|
}
|
|
@@ -364,10 +490,22 @@ export function sketchProject(text) {
|
|
|
364
490
|
return { files: files, entry: SKETCH_ENTRY };
|
|
365
491
|
}
|
|
366
492
|
|
|
367
|
-
/**
|
|
493
|
+
/** One `blob:` URL per byte-identical module text in this page (blobMint's memo). */
|
|
494
|
+
const _blobUrls = new Map();
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A `blob:` module URL for `text` (a page). MEMOIZED by text for the page's lifetime: a re-link of the same text
|
|
498
|
+
* (restart, a Record's parts reads) gets the same URL, so the ESM cache keeps ONE instance and a project's own part
|
|
499
|
+
* file registers its kind once — node's `data:` URLs are content-addressed the same way. `release()` is a no-op:
|
|
500
|
+
* the URL stays valid for the next link of those bytes.
|
|
501
|
+
*/
|
|
368
502
|
export function blobMint(text) {
|
|
369
|
-
|
|
370
|
-
|
|
503
|
+
let url = _blobUrls.get(text);
|
|
504
|
+
if (!url) {
|
|
505
|
+
url = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }));
|
|
506
|
+
_blobUrls.set(text, url);
|
|
507
|
+
}
|
|
508
|
+
return { url: url, release: function () {} };
|
|
371
509
|
}
|
|
372
510
|
|
|
373
511
|
/**
|