@daugasauron/dolly-build 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +11 -2
- package/host/abi.mjs +5 -0
- package/host/requirements.mjs +62 -0
- package/package.json +16 -4
- package/scripts/dolly-build-init/Dollyfile +54 -0
- package/scripts/dolly-build-init/public/index.html +61 -0
- package/scripts/dolly-build-init/public/theme.json +67 -0
- package/scripts/dolly-build-init/public/workspace/README.md +5 -0
- package/scripts/dolly-build.mjs +278 -0
- package/scripts/dollyfile-graph.mjs +41 -0
- package/scripts/image-directory.mjs +85 -0
- package/scripts/page-image-build.mjs +53 -0
- package/scripts/recipe-files.mjs +31 -0
- package/scripts/release-layout.mjs +29 -0
- package/scripts/serve.mjs +112 -0
- package/scripts/share-pages-snapshots.mjs +219 -0
- package/scripts/snapshot-identity.mjs +36 -0
- package/scripts/system-snapshot-format.mjs +103 -0
- package/scripts/wasm-interface.mjs +7 -0
- package/src/alphabetical.mjs +4 -0
- package/src/dollyfile-graph.mjs +127 -0
- package/src/dollyfile-view.mjs +335 -0
- package/src/image-entry.mjs +29 -0
- package/src/image-inputs.mjs +20 -0
- package/src/image-names.mjs +11 -0
- package/src/process-abi.mjs +69 -0
- package/src/snapshot-records.mjs +221 -0
- package/src/static-asset.mjs +124 -0
- package/src/version.mjs +3 -0
- package/src/wasm-interface.mjs +440 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dolly contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @daugasauron/dolly-build
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A project with a Dolly image of your own: a Dollyfile `FROM` the installed
|
|
4
|
+
`@daugasauron/dolly-image-pi-minimal` with the packages it INSTALLs (`@daugasauron/dolly-package-NAME`)
|
|
5
|
+
and your files, built in a browser into a site that any static host serves as it lies.
|
|
6
|
+
@daugasauron/dolly's README (`docs/embedding.md`) says how.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx dolly-build init my-pi git
|
|
10
|
+
npx dolly-build Dollyfile-my-pi my-pi
|
|
11
|
+
python3 -m http.server -d my-pi -b 127.0.0.1 8080
|
|
12
|
+
```
|
package/host/abi.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DOLLY_HOST_RECORD_BYTES as recordBytes, DOLLY_HOST_NAME_BYTES as nameBytes,
|
|
2
|
+
DOLLY_HOST_DIGEST_BYTES as digestBytes, DOLLY_HOST_MAX_RECORDS as maxRecords } from "./abi.mjs";
|
|
3
|
+
import { hex } from "../src/static-asset.mjs";
|
|
4
|
+
|
|
5
|
+
const pattern = /^([a-z][a-z0-9-]{0,30})@(0|[1-9][0-9]{0,4})$/;
|
|
6
|
+
|
|
7
|
+
export function hostRequirement(value) {
|
|
8
|
+
const match = typeof value === "string" && pattern.exec(value);
|
|
9
|
+
if (!match || Number(match[2]) > 65535) throw new TypeError(`invalid host requirement: ${value}`);
|
|
10
|
+
return { name: match[1], version: Number(match[2]) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hostRequirements(values = []) {
|
|
14
|
+
if (!Array.isArray(values)) throw new TypeError("invalid host requirements");
|
|
15
|
+
const versions = new Map();
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
const { name, version } = hostRequirement(value);
|
|
18
|
+
if (versions.has(name) && versions.get(name) !== version) throw new Error(`conflicting host ABI requirements for ${name}`);
|
|
19
|
+
versions.set(name, version);
|
|
20
|
+
}
|
|
21
|
+
if (versions.size > maxRecords) throw new TypeError("too many host requirements");
|
|
22
|
+
return [...versions].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => `${name}@${version}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The modules an executable's dolly.host records require: name@version -> ABI digest.
|
|
26
|
+
export function executableHostRequirements(module) {
|
|
27
|
+
const required = new Map(), decoder = new TextDecoder("utf-8", { fatal: true });
|
|
28
|
+
let records = 0;
|
|
29
|
+
for (const { name, data } of module.customSectionData) {
|
|
30
|
+
if (name !== "dolly.host") continue;
|
|
31
|
+
records += data.length / recordBytes;
|
|
32
|
+
if (data.length === 0 || data.length % recordBytes || records > maxRecords) {
|
|
33
|
+
throw new Error("invalid dolly.host record count");
|
|
34
|
+
}
|
|
35
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
36
|
+
for (let offset = 0; offset < data.length; offset += recordBytes) {
|
|
37
|
+
const bytes = data.subarray(offset, offset + nameBytes), end = bytes.indexOf(0);
|
|
38
|
+
if (end < 1 || bytes.subarray(end).some(byte => byte !== 0) || view.getUint32(offset + nameBytes + 4, true) !== 0) {
|
|
39
|
+
throw new Error("invalid dolly.host record");
|
|
40
|
+
}
|
|
41
|
+
const requirement = `${decoder.decode(bytes.subarray(0, end))}@${view.getUint32(offset + nameBytes, true)}`;
|
|
42
|
+
const digest = hex(data.subarray(offset + recordBytes - digestBytes, offset + recordBytes));
|
|
43
|
+
if (required.has(requirement) && required.get(requirement) !== digest) {
|
|
44
|
+
throw new Error(`conflicting host ABI digests for ${requirement}`);
|
|
45
|
+
}
|
|
46
|
+
required.set(requirement, digest);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
hostRequirements([...required.keys()]);
|
|
50
|
+
return required;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// available: name@version -> the ABI digest its provider implements. A session
|
|
54
|
+
// with nena@0 can enable a module; any other has its image's list.
|
|
55
|
+
export function checkHostAbi(required, available) {
|
|
56
|
+
for (const [requirement, digest] of required) {
|
|
57
|
+
if (!available.has(requirement)) throw new Error(available.has("nena@0")
|
|
58
|
+
? `host module ${requirement} is not enabled: nena enable ${hostRequirement(requirement).name}`
|
|
59
|
+
: `host module ${requirement} is not declared by this image (REQUIRES HOST)`);
|
|
60
|
+
if (available.get(requirement) !== digest) throw new Error(`host module ${requirement} has a different layout than this program was built for`);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
+
"version": "0.1.5",
|
|
3
|
+
"type": "module",
|
|
2
4
|
"name": "@daugasauron/dolly-build",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dolly-build": "scripts/dolly-build.mjs"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@daugasauron/dolly": "0.1.5",
|
|
11
|
+
"playwright-core": "1.63.0"
|
|
12
|
+
},
|
|
13
|
+
"description": "Build a Dolly image of your own, FROM an installed image package, in a browser, into a static site.",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/daugasauron/dolly.git"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Pi reads its instructions, skills, extensions and themes from /home/dolly/.pi/agent/
|
|
2
|
+
# (Pi's docs/configuration.md, in the image under /usr/lib/node_modules/@earendil-works/pi-coding-agent/docs/).
|
|
3
|
+
# A FILE row writes its body, each line indented four spaces, into the image.
|
|
4
|
+
|
|
5
|
+
FILE /home/dolly/.pi/agent/AGENTS.md
|
|
6
|
+
You are the coding agent of this project. Keep answers short, and run the
|
|
7
|
+
project's tests after a change.
|
|
8
|
+
|
|
9
|
+
FILE /home/dolly/.pi/agent/skills/changelog/SKILL.md
|
|
10
|
+
---
|
|
11
|
+
name: changelog
|
|
12
|
+
description: Keeps /workspace/CHANGELOG.md. Use when asked to record a change.
|
|
13
|
+
---
|
|
14
|
+
Add the change under "## Unreleased", one line each, newest first.
|
|
15
|
+
|
|
16
|
+
FILE /home/dolly/.pi/agent/extensions/hello.js
|
|
17
|
+
export default function (pi) {
|
|
18
|
+
pi.registerCommand("hello", {
|
|
19
|
+
description: "Say hello",
|
|
20
|
+
handler: async (name, ctx) => ctx.ui.notify(`Hello, ${name || "world"}, from __NAME__!`, "info"),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# A tool of your own, on the agent's PATH with EXPORTS TOOL: a script
|
|
25
|
+
# (#!/bin/slop, or #!/usr/bin/janis for JavaScript); with the cc package, a C
|
|
26
|
+
# program (FILE /tmp/NAME.c with its body, then SLOP cc /tmp/NAME.c -o /usr/bin/NAME);
|
|
27
|
+
# a file of an npm package, pinned by its SHA-256 (SOURCE https://unpkg.com/PACKAGE@VERSION/FILE
|
|
28
|
+
# SHA256 /usr/lib/FILE, then FILE /usr/lib/FILE to keep it; a script imports it by that path).
|
|
29
|
+
FILE /usr/bin/todo
|
|
30
|
+
#!/bin/slop
|
|
31
|
+
# todo [DIR]: lists the TODO and FIXME notes under DIR (default .).
|
|
32
|
+
rg -n 'TODO|FIXME' "${1:-.}"
|
|
33
|
+
EXPORTS TOOL todo
|
|
34
|
+
|
|
35
|
+
# Pi's colours are public/theme.json, which the page writes at boot.
|
|
36
|
+
|
|
37
|
+
# Pi sends OpenRouter's Claude models through Anthropic's Messages API, whose
|
|
38
|
+
# browser header OpenRouter's CORS refuses: this entry sends them through Chat
|
|
39
|
+
# Completions. An endpoint of your own that speaks OpenAI's API is such an entry
|
|
40
|
+
# too (docs/agent-frontend.md).
|
|
41
|
+
FILE /home/dolly/.pi/agent/models.json
|
|
42
|
+
{ "providers": { "openrouter": { "baseUrl": "https://openrouter.ai/api/v1", "api": "openai-completions",
|
|
43
|
+
"apiKey": "$OPENROUTER_API_KEY", "models": [{ "id": "anthropic/claude-sonnet-5.5" }] } } }
|
|
44
|
+
|
|
45
|
+
# How Pi starts: the key at a prompt that does not show it, the provider and the
|
|
46
|
+
# model (Pi's docs/providers.md). Another provider is this line and the page's
|
|
47
|
+
# policy rule: Anthropic's API with your own key is the second line. /model in Pi
|
|
48
|
+
# changes the model for a session; --offline keeps Pi from requests the policy
|
|
49
|
+
# refuses. Ctrl+D leaves Pi for a shell.
|
|
50
|
+
FILE /etc/dolly/init.slop
|
|
51
|
+
pi-key OPENROUTER_API_KEY --offline --provider openrouter --model anthropic/claude-sonnet-5.5
|
|
52
|
+
# pi-key ANTHROPIC_API_KEY --offline --provider anthropic --model claude-sonnet-5-5
|
|
53
|
+
printf '\nPi exited; this is a Slop shell.\n'
|
|
54
|
+
/bin/foreground -i /bin/slop
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>__NAME__</title>
|
|
4
|
+
<link rel="icon" href="data:,">
|
|
5
|
+
<style>
|
|
6
|
+
/* The font of the popups of Pi's ask_user and show_html, and the scheme of their
|
|
7
|
+
form controls. Their colours and the terminal's are theme.json's. */
|
|
8
|
+
#dolly { --dolly-html-font: ui-monospace, monospace; --dolly-html-color-scheme: dark; }
|
|
9
|
+
</style>
|
|
10
|
+
<main id="dolly" style="position:fixed;inset:0"></main>
|
|
11
|
+
<script type="module">
|
|
12
|
+
// Dolly needs cross-origin isolation. Where the host sends no COOP and COEP
|
|
13
|
+
// headers, coi-serviceworker.js adds them: the page registers it and loads again
|
|
14
|
+
// under it, once; boot() says so if that did not isolate it.
|
|
15
|
+
if (!crossOriginIsolated && navigator.serviceWorker && !sessionStorage.getItem("dolly-isolation-retry")) {
|
|
16
|
+
sessionStorage.setItem("dolly-isolation-retry", "1");
|
|
17
|
+
await navigator.serviceWorker.register("./coi-serviceworker.js");
|
|
18
|
+
await navigator.serviceWorker.ready;
|
|
19
|
+
location.reload();
|
|
20
|
+
await new Promise(() => {});
|
|
21
|
+
}
|
|
22
|
+
sessionStorage.removeItem("dolly-isolation-retry");
|
|
23
|
+
const { boot } = await import("./dolly/src/browser.mjs");
|
|
24
|
+
const { images, image, modules } = await import("./image/index.mjs");
|
|
25
|
+
|
|
26
|
+
// The hosts the agent may reach, and nothing else: the model's provider here,
|
|
27
|
+
// one rule per host the agent needs (api.github.com, pypi.org). A rule names an
|
|
28
|
+
// origin, its paths and methods, and the header that carries the key (docs/http.md
|
|
29
|
+
// in @daugasauron/dolly). Hosts without CORS headers go through a relay of your
|
|
30
|
+
// own (docs/agent-frontend.md).
|
|
31
|
+
const policy = { rules: [
|
|
32
|
+
{ origin: "https://openrouter.ai", pathPrefix: "/api/v1/", methods: ["GET", "POST"], credentialHeaders: ["authorization"] },
|
|
33
|
+
// { origin: "https://api.anthropic.com", pathPrefix: "/v1/", methods: ["POST"], credentialHeaders: ["x-api-key"] },
|
|
34
|
+
] };
|
|
35
|
+
|
|
36
|
+
// The project: workspace/ is written into /workspace at each boot. dolly-build
|
|
37
|
+
// lists its files in workspace.json beside this page. Pi's tools can read these
|
|
38
|
+
// files; they are not for secrets.
|
|
39
|
+
const listing = await fetch("./workspace.json");
|
|
40
|
+
if (!listing.ok) throw new Error("workspace.json must list the files under workspace/, as dolly-build writes it");
|
|
41
|
+
const files = {};
|
|
42
|
+
await Promise.all((await listing.json()).map(async path => {
|
|
43
|
+
const url = `./workspace/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
44
|
+
files[`/workspace/${path}`] = new Uint8Array(await (await fetch(url)).arrayBuffer());
|
|
45
|
+
}));
|
|
46
|
+
// The sandbox's colours, all in theme.json: Pi's theme (Pi's docs/themes.md), written
|
|
47
|
+
// over the image's, whose background, text, accent and muted the terminal and the
|
|
48
|
+
// popups take too (docs/embedding.md in @daugasauron/dolly).
|
|
49
|
+
const theme = await (await fetch("./theme.json")).text();
|
|
50
|
+
files["/home/dolly/.pi/agent/themes/dolly.json"] = theme;
|
|
51
|
+
const { vars } = JSON.parse(theme), look = document.querySelector("#dolly").style;
|
|
52
|
+
look.setProperty("--dolly-background", vars.background);
|
|
53
|
+
look.setProperty("--dolly-color", vars.text);
|
|
54
|
+
look.setProperty("--dolly-accent", vars.accent);
|
|
55
|
+
look.setProperty("--dolly-display-palette-8", vars.muted);
|
|
56
|
+
|
|
57
|
+
// Save (Ctrl+Shift+S) keeps the sandbox's state in this browser and names it in the
|
|
58
|
+
// address, ?session=NAME: the page then opens that save, as it was, in place of a fresh start.
|
|
59
|
+
const session = new URLSearchParams(location.search).get("session") ?? undefined;
|
|
60
|
+
await boot(document.querySelector("#dolly"), { images, image, modules, http: { policy }, files, session });
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dolly",
|
|
3
|
+
"vars": {
|
|
4
|
+
"accent": "#f2d45c",
|
|
5
|
+
"text": "#e8e3d7",
|
|
6
|
+
"muted": "#77736c",
|
|
7
|
+
"background": "#262626",
|
|
8
|
+
"panel": "#30302e"
|
|
9
|
+
},
|
|
10
|
+
"colors": {
|
|
11
|
+
"accent": "accent",
|
|
12
|
+
"border": "muted",
|
|
13
|
+
"borderAccent": "accent",
|
|
14
|
+
"borderMuted": "muted",
|
|
15
|
+
"success": "accent",
|
|
16
|
+
"error": "accent",
|
|
17
|
+
"warning": "accent",
|
|
18
|
+
"muted": "muted",
|
|
19
|
+
"dim": "muted",
|
|
20
|
+
"text": "text",
|
|
21
|
+
"thinkingText": "muted",
|
|
22
|
+
"selectedBg": "panel",
|
|
23
|
+
"scrollbarThumb": "muted",
|
|
24
|
+
"searchMatchBg": "panel",
|
|
25
|
+
"searchMatchText": "accent",
|
|
26
|
+
"userMessageBg": "panel",
|
|
27
|
+
"userMessageText": "text",
|
|
28
|
+
"customMessageBg": "panel",
|
|
29
|
+
"customMessageText": "text",
|
|
30
|
+
"customMessageLabel": "accent",
|
|
31
|
+
"toolPendingBg": "background",
|
|
32
|
+
"toolSuccessBg": "background",
|
|
33
|
+
"toolErrorBg": "background",
|
|
34
|
+
"toolTitle": "accent",
|
|
35
|
+
"toolOutput": "text",
|
|
36
|
+
"mdHeading": "accent",
|
|
37
|
+
"mdLink": "accent",
|
|
38
|
+
"mdLinkUrl": "muted",
|
|
39
|
+
"mdCode": "accent",
|
|
40
|
+
"mdCodeBlock": "text",
|
|
41
|
+
"mdCodeBlockBorder": "muted",
|
|
42
|
+
"mdQuote": "muted",
|
|
43
|
+
"mdQuoteBorder": "muted",
|
|
44
|
+
"mdHr": "muted",
|
|
45
|
+
"mdListBullet": "accent",
|
|
46
|
+
"toolDiffAdded": "accent",
|
|
47
|
+
"toolDiffRemoved": "accent",
|
|
48
|
+
"toolDiffContext": "muted",
|
|
49
|
+
"syntaxComment": "muted",
|
|
50
|
+
"syntaxKeyword": "accent",
|
|
51
|
+
"syntaxFunction": "text",
|
|
52
|
+
"syntaxVariable": "text",
|
|
53
|
+
"syntaxString": "accent",
|
|
54
|
+
"syntaxNumber": "accent",
|
|
55
|
+
"syntaxType": "accent",
|
|
56
|
+
"syntaxOperator": "accent",
|
|
57
|
+
"syntaxPunctuation": "muted",
|
|
58
|
+
"thinkingOff": "muted",
|
|
59
|
+
"thinkingMinimal": "accent",
|
|
60
|
+
"thinkingLow": "accent",
|
|
61
|
+
"thinkingMedium": "accent",
|
|
62
|
+
"thinkingHigh": "accent",
|
|
63
|
+
"thinkingXhigh": "accent",
|
|
64
|
+
"thinkingMax": "accent",
|
|
65
|
+
"bashMode": "accent"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// An npm user's image: builds a Dollyfile FROM an installed image package, with
|
|
3
|
+
// the packages it INSTALLs from their packages, in a browser with that browser's
|
|
4
|
+
// Dolly runtime, into a site a static host serves as it lies (docs/embedding.md,
|
|
5
|
+
// "Your own image").
|
|
6
|
+
// npx dolly-build init NAME [PACKAGE…] writes Dollyfile-NAME FROM pi-minimal and public/ (the page)
|
|
7
|
+
// npx dolly-build Dollyfile-NAME OUT [--browser chrome|chromium|firefox]
|
|
8
|
+
// OUT is the files of public/, workspace.json listing public/workspace/, the runtime
|
|
9
|
+
// (dolly/), the host modules the image declares (dolly-host-NAME/),
|
|
10
|
+
// coi-serviceworker.js for hosts without isolation headers, and image/, the image's
|
|
11
|
+
// directory as an image package lays it out (catalog, metadata, packs, recipes,
|
|
12
|
+
// amy-index.txt, index.mjs). It runs in the directory whose node_modules/@daugasauron/
|
|
13
|
+
// holds the runtime, the image and package directories and the host module packages
|
|
14
|
+
// (pnpm links there what it keeps elsewhere); the runtime runs only in the browser.
|
|
15
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { parseArgs } from "node:util";
|
|
22
|
+
import { chromium, firefox } from "playwright-core";
|
|
23
|
+
import { amyIndex, catalogJson, catalogRecord, heldPackages, indexDescriptions, indexSource, metadataJson, snapshotMetadata } from "./image-directory.mjs";
|
|
24
|
+
import { buildImageInPage, savePageSnapshot } from "./page-image-build.mjs";
|
|
25
|
+
import { isolationHeaders, mimeTypes } from "./serve.mjs";
|
|
26
|
+
import { ensureSnapshotPacks } from "./share-pages-snapshots.mjs";
|
|
27
|
+
import { verifySnapshotIdentity } from "./snapshot-identity.mjs";
|
|
28
|
+
import { decodeSystemSnapshot } from "./system-snapshot-format.mjs";
|
|
29
|
+
import { readWasmInterface } from "./wasm-interface.mjs";
|
|
30
|
+
import { loadRecipeGraph } from "../src/dollyfile-graph.mjs";
|
|
31
|
+
import { imageFileName, inspectDollyfile } from "../src/dollyfile-view.mjs";
|
|
32
|
+
import { imageInputs } from "../src/image-inputs.mjs";
|
|
33
|
+
import { validName } from "../src/image-names.mjs";
|
|
34
|
+
import { siteReference, sitePath } from "../src/static-asset.mjs";
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
console.error(`dolly-build: ${message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const scope = resolve("node_modules/@daugasauron");
|
|
41
|
+
if (!existsSync(join(scope, "dolly"))) fail("run it where node_modules/@daugasauron/dolly is installed");
|
|
42
|
+
const load = path => import(pathToFileURL(path).href);
|
|
43
|
+
const { positionals, values: { browser: browserName } } = parseArgs({ allowPositionals: true,
|
|
44
|
+
options: { browser: { type: "string", default: "chrome" } } });
|
|
45
|
+
// Firefox's system add-on updater resolves its host even behind a proxy.
|
|
46
|
+
const browsers = {
|
|
47
|
+
chrome: options => chromium.launch({ channel: "chrome", headless: true, ...options }),
|
|
48
|
+
chromium: options => chromium.launch({ headless: true, ...options }),
|
|
49
|
+
firefox: options => firefox.launch({ headless: true, firefoxUserPrefs: { "extensions.systemAddon.update.enabled": false }, ...options }),
|
|
50
|
+
};
|
|
51
|
+
const usage = "usage: dolly-build init NAME [PACKAGE...] | Dollyfile-NAME OUT [--browser chrome|chromium|firefox]";
|
|
52
|
+
const init = positionals[0] === "init";
|
|
53
|
+
if (!browsers[browserName] || (init ? positionals.length < 2 : positionals.length !== 2)) fail(usage);
|
|
54
|
+
|
|
55
|
+
// The installed image and package directories (dolly-image-NAME, dolly-package-NAME):
|
|
56
|
+
// each holds its own image's packs, and the catalog and metadata of that image's
|
|
57
|
+
// closure. npm installs the packages a package installs beside it, pnpm beside its
|
|
58
|
+
// real path.
|
|
59
|
+
const directories = [];
|
|
60
|
+
for (const pending = [scope]; pending.length;) {
|
|
61
|
+
const parent = pending.pop();
|
|
62
|
+
for (const name of (await readdir(parent)).filter(name => /^dolly-(image|package)-/.test(name))) {
|
|
63
|
+
const directory = await realpath(join(parent, name));
|
|
64
|
+
if (directories.some(found => found.directory === directory)) continue;
|
|
65
|
+
directories.push({ directory, package: `@daugasauron/${name}`, image: name.replace(/^dolly-(image|package)-/, ""),
|
|
66
|
+
catalog: JSON.parse(await readFile(join(directory, "dist/dolly-images.json"), "utf8")) });
|
|
67
|
+
pending.push(dirname(directory));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const definitionOf = ({ catalog, image }) => catalog.images.find(candidate => candidate.image === image);
|
|
71
|
+
const installed = (image, kind) => directories.find(candidate => candidate.image === image)
|
|
72
|
+
?? fail(`${image} is not installed: npm install @daugasauron/dolly-${kind}-${image}`);
|
|
73
|
+
// The directory whose own image a row names, at the row's pin.
|
|
74
|
+
const holder = reference => directories.find(candidate => {
|
|
75
|
+
const definition = definitionOf(candidate);
|
|
76
|
+
return siteReference(definition.dollyfile) === reference.location && definition.sha256 === reference.sha256;
|
|
77
|
+
});
|
|
78
|
+
// The host module packages: the runtime's own and one package each.
|
|
79
|
+
const modules = new Map();
|
|
80
|
+
for (const name of await readdir(join(scope, "dolly/host"))) {
|
|
81
|
+
if (existsSync(join(scope, "dolly/host", name, "module.json"))) modules.set(name, `dolly/host/${name}/module.json`);
|
|
82
|
+
}
|
|
83
|
+
for (const name of (await readdir(scope)).filter(name => name.startsWith("dolly-host-"))) {
|
|
84
|
+
modules.set(JSON.parse(await readFile(join(scope, name, "module.json"), "utf8")).name, `${name}/module.json`);
|
|
85
|
+
}
|
|
86
|
+
const hostPackage = name => `@daugasauron/dolly-host-${name === "download" ? "save" : name}`;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (init) await scaffold(positionals[1], positionals.slice(2));
|
|
90
|
+
else await build(...positionals.map(path => resolve(path)));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
fail(error.message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A project: the recipe (FROM pi-minimal at its pin, INSTALL of each PACKAGE,
|
|
96
|
+
// the template's rows, the base's ENTRY) and public/, the page's files: the page,
|
|
97
|
+
// its theme and the workspace.
|
|
98
|
+
async function scaffold(name, packages) {
|
|
99
|
+
if (!validName(name)) fail(`${name}: a name is lowercase letters, digits and dashes, at most 32 bytes`);
|
|
100
|
+
const base = definitionOf(installed("pi-minimal", "image"));
|
|
101
|
+
const installs = packages.map(image => definitionOf(installed(image, "package")));
|
|
102
|
+
// snapshot@0 is the page's Save: a save is kept in the browser, and the page opens it again.
|
|
103
|
+
const hosts = [...new Set([base, ...installs].flatMap(definition => definition.hostRequirements).concat("snapshot@0"))].sort();
|
|
104
|
+
const row = (operation, definition) => `${operation} ${siteReference(definition.dollyfile)} ${definition.sha256}\n`;
|
|
105
|
+
const template = async file => (await readFile(join(import.meta.dirname, "dolly-build-init", file), "utf8")).replaceAll("__NAME__", name);
|
|
106
|
+
const files = {
|
|
107
|
+
[`Dollyfile-${name}`]: `DOLLY 7\nAPPLICATION ${name}\n${hosts.map(host => `REQUIRES HOST ${host}\n`).join("")}\n` +
|
|
108
|
+
row("FROM", base) + installs.map(definition => row("INSTALL", definition)).join("") + "\n" + await template("Dollyfile") +
|
|
109
|
+
`\nENTRY ${base.entry.join(" ")}\n`,
|
|
110
|
+
"public/index.html": await template("public/index.html"),
|
|
111
|
+
"public/theme.json": await template("public/theme.json"),
|
|
112
|
+
"public/workspace/README.md": await template("public/workspace/README.md"),
|
|
113
|
+
};
|
|
114
|
+
const present = Object.keys(files).filter(file => existsSync(file));
|
|
115
|
+
if (present.length) fail(`${present.join(", ")}: already here`);
|
|
116
|
+
await mkdir("public/workspace", { recursive: true });
|
|
117
|
+
for (const [file, text] of Object.entries(files)) await writeFile(file, text);
|
|
118
|
+
// The site and node_modules are built, not kept: a .gitignore, unless the project has one.
|
|
119
|
+
if (!existsSync(".gitignore")) await writeFile(".gitignore", `node_modules/\n${name}/\n`);
|
|
120
|
+
const missing = hosts.map(host => host.split("@")[0]).filter(name => !modules.has(name));
|
|
121
|
+
console.log(`dolly-build: wrote ${Object.keys(files).join(", ")}\n` +
|
|
122
|
+
(missing.length ? ` npm install ${missing.map(hostPackage).join(" ")} the recipe declares ${missing.join(", ")}\n` : "") +
|
|
123
|
+
` npx dolly-build Dollyfile-${name} ${name} builds the image and the site, ${name}/ (again after a change)\n` +
|
|
124
|
+
` python3 -m http.server -d ${name} -b 127.0.0.1 8080 serves it, as any static host does`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function build(recipePath, outArgument) {
|
|
128
|
+
const filename = basename(recipePath), source = await readFile(recipePath, "utf8");
|
|
129
|
+
const parsed = inspectDollyfile(source, filename), image = parsed.image;
|
|
130
|
+
if (imageFileName(filename) !== image) fail(`${filename}: name it Dollyfile-${image}`);
|
|
131
|
+
if (!parsed.from) fail(`${filename}: FROM an installed image`);
|
|
132
|
+
if (!existsSync("public/index.html")) fail("public/index.html: the page of the site (dolly-build init writes one)");
|
|
133
|
+
// A rebuild replaces the site it wrote before, and nothing else.
|
|
134
|
+
const previous = join(outArgument, "image");
|
|
135
|
+
if (existsSync(outArgument) && !existsSync(join(previous, "dist/dolly-images.json"))) {
|
|
136
|
+
fail(`${outArgument} exists and is not a site dolly-build wrote: remove it, or name another`);
|
|
137
|
+
}
|
|
138
|
+
// Every row that names an image names one of the installed directories at its pin.
|
|
139
|
+
const holders = parsed.artifacts.map(reference => holder(reference) ?? fail(`${filename}:${reference.line}: no installed ` +
|
|
140
|
+
`package holds ${reference.location} at that pin (dolly-build init writes the rows of the installed ones)`));
|
|
141
|
+
// One catalog of their closures: the same image at the same pin in each.
|
|
142
|
+
const records = new Map(), staticSources = new Map(), first = new Map();
|
|
143
|
+
for (const directory of holders) {
|
|
144
|
+
for (const record of directory.catalog.images) {
|
|
145
|
+
if (records.has(record.image) && records.get(record.image).sha256 !== record.sha256) {
|
|
146
|
+
fail(`${directory.package} holds ${record.image} at another pin than ${first.get(record.image)}: install packages of one version`);
|
|
147
|
+
}
|
|
148
|
+
records.set(record.image, record);
|
|
149
|
+
first.set(record.image, first.get(record.image) ?? directory.package);
|
|
150
|
+
}
|
|
151
|
+
for (const entry of directory.catalog.staticSources) staticSources.set(entry.path, entry);
|
|
152
|
+
}
|
|
153
|
+
if (records.has(image)) fail(`${image} is already an image of ${first.get(image)}`);
|
|
154
|
+
const declared = parsed.hostRequirements.map(requirement => requirement.split("@")[0]);
|
|
155
|
+
const missing = declared.filter(name => !modules.has(name));
|
|
156
|
+
if (missing.length) fail(`${image} declares ${missing.join(", ")}: install ${missing.map(hostPackage).join(", ")}`);
|
|
157
|
+
const runtime = join(scope, "dolly/dist");
|
|
158
|
+
const identities = { buildId: (await load(join(runtime, "dolly-build-id.mjs"))).DOLLY_BUILD_ID,
|
|
159
|
+
imageBuildId: (await load(join(runtime, "dolly-image-build-id.mjs"))).DOLLY_IMAGE_BUILD_ID };
|
|
160
|
+
|
|
161
|
+
const staging = await mkdtemp(join(dirname(outArgument), ".dolly-build-"));
|
|
162
|
+
try {
|
|
163
|
+
// The page's files and the listing of its workspace, the runtime and the host
|
|
164
|
+
// modules the image declares, as their packages hold them, and the worker
|
|
165
|
+
// that isolates the page where the host sends no headers.
|
|
166
|
+
await cp("public", staging, { recursive: true });
|
|
167
|
+
const workspace = join(staging, "workspace");
|
|
168
|
+
if (existsSync(workspace)) {
|
|
169
|
+
const files = (await readdir(workspace, { recursive: true, withFileTypes: true })).filter(entry => entry.isFile())
|
|
170
|
+
.map(entry => relative(workspace, join(entry.parentPath, entry.name)).split(sep).join("/")).sort();
|
|
171
|
+
await writeFile(join(staging, "workspace.json"), `${JSON.stringify(files)}\n`);
|
|
172
|
+
}
|
|
173
|
+
for (const name of new Set(["dolly", ...declared.map(module => modules.get(module).split("/")[0])])) {
|
|
174
|
+
await cp(join(scope, name), join(staging, name), { recursive: true, dereference: true });
|
|
175
|
+
}
|
|
176
|
+
await cp(join(scope, "dolly/coi-serviceworker.js"), join(staging, "coi-serviceworker.js"));
|
|
177
|
+
// The image a build of this recipe on this runtime wrote before is kept.
|
|
178
|
+
if (await built(previous, image, source, identities)) await rename(previous, join(staging, "image"));
|
|
179
|
+
else await buildImage(join(staging, "image"), { filename, source, image, parsed, holders, records, staticSources, declared, identities });
|
|
180
|
+
await rm(outArgument, { recursive: true, force: true });
|
|
181
|
+
await rename(staging, outArgument);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await rm(staging, { recursive: true, force: true });
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
console.log(`dolly-build: ${relative(process.cwd(), outArgument) || "."} is the site of ${image}: a static host serves it as it lies`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Whether DIRECTORY holds IMAGE built from SOURCE by the runtime of these IDENTITIES.
|
|
190
|
+
async function built(directory, image, source, identities) {
|
|
191
|
+
const read = async file => JSON.parse(await readFile(join(directory, "dist", file), "utf8").catch(() => "null"));
|
|
192
|
+
const [catalog, metadata] = await Promise.all([read("dolly-images.json"), read(`dolly-${image}-system-snapshot.json`)]);
|
|
193
|
+
return catalog?.images.find(record => record.image === image)?.sha256 === createHash("sha256").update(source).digest("hex") &&
|
|
194
|
+
metadata?.buildId === identities.imageBuildId && metadata?.runtimeBuildId === identities.buildId;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The image directory, laid out as an image package: the closures' metadata and
|
|
198
|
+
// recipes, this recipe beside them, the image's metadata and packs, its package
|
|
199
|
+
// index and its index.mjs. Every installed directory's packs are served for the
|
|
200
|
+
// build alone: a package's directory holds its own, and npm installs the packages
|
|
201
|
+
// it installs beside it.
|
|
202
|
+
async function buildImage(directory, { filename, source, image, parsed, holders, records, staticSources, declared, identities }) {
|
|
203
|
+
await mkdir(directory);
|
|
204
|
+
for (const holding of new Set(holders)) {
|
|
205
|
+
await cp(holding.directory, directory, { recursive: true, filter: path => ![
|
|
206
|
+
join(holding.directory, "dist/packs"), join(holding.directory, "dist/dolly-images.json"), join(holding.directory, "index.mjs"),
|
|
207
|
+
join(holding.directory, "amy-index.txt"), join(holding.directory, "package.json"), join(holding.directory, "README.md")].includes(path) });
|
|
208
|
+
}
|
|
209
|
+
await writeFile(join(directory, filename), source);
|
|
210
|
+
const graph = await loadRecipeGraph(reference => readFile(join(directory, sitePath(reference).slice(1))), siteReference(filename));
|
|
211
|
+
const catalog = [...records.values(), catalogRecord({ image, filename, source, parsed }, graph)];
|
|
212
|
+
await writeFile(join(directory, "dist/dolly-images.json"), catalogJson(catalog, [...staticSources.values()]));
|
|
213
|
+
|
|
214
|
+
const page = "<!doctype html><title>dolly-build</title>";
|
|
215
|
+
// The packages at their names (the runtime at /dolly/, the host modules beside it).
|
|
216
|
+
const roots = [["/", scope], ["/image/", directory],
|
|
217
|
+
...directories.map(installed => ["/image/dist/packs/", join(installed.directory, "dist/packs")])];
|
|
218
|
+
const server = createServer(async (request, response) => {
|
|
219
|
+
const path = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
|
|
220
|
+
if (path === "/") return response.writeHead(200, { ...isolationHeaders, "content-type": "text/html" }).end(page);
|
|
221
|
+
for (const [prefix, root] of roots.toReversed().filter(([prefix]) => path.startsWith(prefix))) {
|
|
222
|
+
const file = resolve(root, path.slice(prefix.length));
|
|
223
|
+
if (!file.startsWith(root + sep)) break;
|
|
224
|
+
const body = await readFile(file).catch(() => null);
|
|
225
|
+
if (body) return response.writeHead(200, { ...isolationHeaders,
|
|
226
|
+
"content-type": mimeTypes.get(extname(file)) ?? "application/octet-stream" }).end(body);
|
|
227
|
+
}
|
|
228
|
+
response.writeHead(404).end();
|
|
229
|
+
});
|
|
230
|
+
// The browser's own requests (its vendor's updates, clock, autofill) go to a proxy that
|
|
231
|
+
// refuses them: it reaches this server and the recipes' sources alone.
|
|
232
|
+
const refusal = createServer((request, response) => response.writeHead(403).end()).on("connect", (request, socket) => socket.destroy());
|
|
233
|
+
await Promise.all([server, refusal].map(listener => new Promise(listening => listener.listen(0, "127.0.0.1", listening))));
|
|
234
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
235
|
+
// The recipes' own upstream URLs are the only network the build may use.
|
|
236
|
+
const rules = graph.records.flatMap(record => record.sources.filter(entry => sitePath(entry.location) === null))
|
|
237
|
+
.map(entry => ({ origin: new URL(entry.location).origin, path: new URL(entry.location).pathname, methods: ["GET"] }));
|
|
238
|
+
const browser = await browsers[browserName]({ proxy: { server: `http://127.0.0.1:${refusal.address().port}`,
|
|
239
|
+
bypass: [...new Set(["127.0.0.1", ...rules.map(rule => new URL(rule.origin).hostname)])].join(",") } });
|
|
240
|
+
let inputs;
|
|
241
|
+
try {
|
|
242
|
+
const tab = await browser.newPage();
|
|
243
|
+
tab.setDefaultTimeout(0);
|
|
244
|
+
await tab.exposeFunction("dollyBuildLog", text => void process.stderr.write(text));
|
|
245
|
+
await tab.addInitScript(rules => { globalThis.DOLLY_HTTP_POLICY = { maxRequests: 256, rules }; }, rules);
|
|
246
|
+
await tab.goto(`${origin}/`);
|
|
247
|
+
({ inputs } = await tab.evaluate(`(${buildImageInPage})(${JSON.stringify(`${origin}/dolly/`)}, ` +
|
|
248
|
+
`${JSON.stringify(`${origin}/image/`)}, ${JSON.stringify(image)}, undefined, ` +
|
|
249
|
+
`${JSON.stringify([...modules.values()].map(path => `${origin}/${path}`))})`));
|
|
250
|
+
await savePageSnapshot(tab, join(directory, `dist/dolly-${image}-system.snapshot`));
|
|
251
|
+
} finally {
|
|
252
|
+
await browser.close();
|
|
253
|
+
server.close();
|
|
254
|
+
refusal.close();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Checked and described as the catalog's images are, by the runtime it runs on.
|
|
258
|
+
const runtime = join(scope, "dolly/dist");
|
|
259
|
+
const snapshotPath = join(directory, `dist/dolly-${image}-system.snapshot`), snapshot = await readFile(snapshotPath);
|
|
260
|
+
const decoded = decodeSystemSnapshot(snapshot);
|
|
261
|
+
const entry = verifySnapshotIdentity({ image, source, parsed }, graph, decoded,
|
|
262
|
+
await readWasmInterface(join(runtime, "dolly-process-0.wasm")),
|
|
263
|
+
(await load(join(runtime, "dolly-process-abi.mjs"))).DOLLY_PROCESS_ABI_DIGEST);
|
|
264
|
+
const packed = await ensureSnapshotPacks(join(directory, "dist"),
|
|
265
|
+
snapshotMetadata(image, graph, imageInputs(inputs), snapshot, decoded, entry, identities));
|
|
266
|
+
await writeFile(join(directory, `dist/dolly-${image}-system-snapshot.json`), metadataJson(packed));
|
|
267
|
+
await rm(snapshotPath);
|
|
268
|
+
|
|
269
|
+
// The packages it holds, described as the installed directories' indexes describe them.
|
|
270
|
+
const descriptions = new Map();
|
|
271
|
+
for (const installed of directories) {
|
|
272
|
+
const text = await readFile(join(installed.directory, "amy-index.txt"), "utf8").catch(() => "");
|
|
273
|
+
for (const [name, description] of indexDescriptions(text)) descriptions.set(name, description);
|
|
274
|
+
}
|
|
275
|
+
const held = heldPackages(catalog, image);
|
|
276
|
+
await writeFile(join(directory, "amy-index.txt"), amyIndex(catalog.filter(record => held.includes(record.image)), descriptions));
|
|
277
|
+
await writeFile(join(directory, "index.mjs"), indexSource(image, declared.map(name => `../${modules.get(name)}`)));
|
|
278
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadRecipeGraph } from "../src/dollyfile-graph.mjs";
|
|
4
|
+
import { recipeFileName } from "../src/dollyfile-view.mjs";
|
|
5
|
+
import { recipeFiles } from "./recipe-files.mjs";
|
|
6
|
+
import { siteReference } from "../src/static-asset.mjs";
|
|
7
|
+
|
|
8
|
+
// Loads catalog recipe graphs from a project directory, sharing parsed recipes.
|
|
9
|
+
export function createDollyfileGraphLoader(projectDir) {
|
|
10
|
+
const recipes = new Map();
|
|
11
|
+
let files;
|
|
12
|
+
async function read(reference) {
|
|
13
|
+
files ??= recipeFiles(projectDir);
|
|
14
|
+
const path = (await files).get(reference);
|
|
15
|
+
if (!path) throw new Error(`${reference}: no such recipe`);
|
|
16
|
+
return readFile(resolve(projectDir, path));
|
|
17
|
+
}
|
|
18
|
+
return (rootFilename = "Dollyfile") => loadRecipeGraph(read, siteReference(rootFilename), recipes);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadDollyfileGraph(projectDir, rootFilename = "Dollyfile") {
|
|
22
|
+
return createDollyfileGraphLoader(projectDir)(rootFilename);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The recipe provenance /bin/dollyfile records, in its order.
|
|
26
|
+
export function recipeRecords(graph) {
|
|
27
|
+
const records = [], seen = new Set();
|
|
28
|
+
function visit(record) {
|
|
29
|
+
if (seen.has(record.location)) return;
|
|
30
|
+
seen.add(record.location);
|
|
31
|
+
for (const { target } of record.artifactTargets) visit(target);
|
|
32
|
+
records.push({
|
|
33
|
+
name: record.name, locator: record.location,
|
|
34
|
+
sourcePath: record.location,
|
|
35
|
+
retainedPath: `/etc/dolly/recipes/${recipeFileName(record.location)}`,
|
|
36
|
+
sha256: record.sha256, byteLength: Buffer.byteLength(record.source),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
visit(graph.root);
|
|
40
|
+
return records;
|
|
41
|
+
}
|