@solidrt/cli 0.0.32 → 0.0.34
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/AGENTS.md +3 -5
- package/fonts/NotoSans.ttf +0 -0
- package/fonts/NotoSansMono.ttf +0 -0
- package/fonts/NotoSerif.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/package.json +8 -7
- package/scaffold/AGENTS.md +26 -0
- package/scaffold/gitignore +2 -1
- package/scaffold/package.json +4 -4
- package/server/cache.ts +5 -3
- package/server/main.ts +26 -39
- package/server/rebuild.ts +11 -6
- package/server/state.ts +7 -2
- package/server/tunnel.ts +5 -5
- package/src/args.ts +25 -2
- package/src/bundler.ts +5 -1
- package/src/commands/client.ts +11 -7
- package/src/commands/init.ts +6 -0
- package/src/commands/pack.ts +43 -7
- package/src/commands/server.ts +11 -3
- package/src/dev-client.ts +2 -2
- package/src/dev-server.ts +29 -9
- package/src/fonts.ts +91 -0
- package/src/pack-folder.ts +112 -0
- package/src/packer.ts +55 -12
- package/src/project.ts +171 -0
- package/src/repl.ts +11 -3
- package/src/util.ts +6 -0
- package/src/watcher.ts +53 -27
package/src/commands/pack.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { values, source } from "../args"
|
|
2
|
-
import { bundleFlux, bundleSolid } from "../bundler"
|
|
3
|
-
import {
|
|
2
|
+
import { bundleFlux, bundleSolid, compileToBytecode } from "../bundler"
|
|
3
|
+
import { resolvePackFonts } from "../fonts"
|
|
4
|
+
import { loadAppIdentity } from "../project"
|
|
5
|
+
import { packFlux, packSolid } from "../packer"
|
|
6
|
+
import { buildPackFolder, writePackFolder } from "../pack-folder"
|
|
7
|
+
import { requireBinary } from "../util"
|
|
8
|
+
import { resolve } from "node:path"
|
|
4
9
|
|
|
5
10
|
// Write the packed executable, mark it runnable, and report its size.
|
|
6
11
|
async function writeExecutable(packed: Buffer, outfile: string) {
|
|
@@ -12,14 +17,45 @@ async function writeExecutable(packed: Buffer, outfile: string) {
|
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export async function runPackCommand() {
|
|
20
|
+
if (values.flux) {
|
|
21
|
+
if (values.folder) {
|
|
22
|
+
console.error("--folder is for app packs; flux scripts have no folder output")
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
let outfile = values.output ?? source!.replace(/\.[jt]sx?$/, "")
|
|
26
|
+
if (process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")) {
|
|
27
|
+
outfile += ".exe"
|
|
28
|
+
}
|
|
29
|
+
await writeExecutable(await packFlux(await bundleFlux(source!)), outfile)
|
|
30
|
+
process.exit()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Both solidrt outputs are the same canonical pack: manifest + bundle.bin +
|
|
34
|
+
// assets (fonts included). --folder writes it as a flat folder next to a
|
|
35
|
+
// bare runner; the default single-file exe carries it as trailer sections.
|
|
36
|
+
let identity = loadAppIdentity(source!)
|
|
37
|
+
console.log(`>> app: ${identity.appId} (${identity.org} / ${identity.displayName})`)
|
|
38
|
+
if (identity.defaulted) {
|
|
39
|
+
console.warn('>> warning: no "solidrt.appId" in package.json; set a stable reverse-DNS id before distributing')
|
|
40
|
+
}
|
|
41
|
+
let fonts = resolvePackFonts(source!)
|
|
42
|
+
console.log(`>> fonts: ${fonts.length ? fonts.map((f) => f.alias).join(", ") : "none"}`)
|
|
43
|
+
|
|
44
|
+
let bytecode = await compileToBytecode(await bundleSolid())
|
|
45
|
+
let folder = buildPackFolder(source!, bytecode)
|
|
46
|
+
|
|
47
|
+
if (values.folder) {
|
|
48
|
+
let outDir = values.output ?? "dist"
|
|
49
|
+
writePackFolder(outDir, requireBinary("solidrt"), bytecode, folder)
|
|
50
|
+
console.log(`>> wrote pack folder to ${resolve(outDir)}`)
|
|
51
|
+
process.exit()
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
let outfile = values.output ?? source!.replace(/\.[jt]sx?$/, "")
|
|
16
55
|
// On Windows the packed image is a PE executable; it needs a .exe name to run.
|
|
17
56
|
if (process.platform === "win32" && !outfile.toLowerCase().endsWith(".exe")) {
|
|
18
57
|
outfile += ".exe"
|
|
19
58
|
}
|
|
20
|
-
|
|
21
|
-
? await packRunner("fluxrt", await bundleFlux(source!))
|
|
22
|
-
: await packRunner("solidrt", await bundleSolid())
|
|
23
|
-
await writeExecutable(packed, outfile)
|
|
59
|
+
await writeExecutable(packSolid(folder, bytecode), outfile)
|
|
24
60
|
process.exit()
|
|
25
|
-
}
|
|
61
|
+
}
|
package/src/commands/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import pkg from "../../package.json"
|
|
|
2
2
|
import { source, isSource, isPrebuilt, values } from "../args"
|
|
3
3
|
import { state, shutdown } from "../util"
|
|
4
4
|
import { bundle } from "../bundler"
|
|
5
|
+
import { buildManifest, projectDirFor } from "../project"
|
|
5
6
|
import { startServer, buildReload, sendReload, showBuildFailure } from "../dev-server"
|
|
6
7
|
import { startRepl } from "../repl"
|
|
7
8
|
import { startWatcher } from "../watcher"
|
|
@@ -14,6 +15,7 @@ export async function runServerCommand() {
|
|
|
14
15
|
// Initialize state from args
|
|
15
16
|
state.source = source
|
|
16
17
|
state.sourceDir = source ? dirname(resolve(source)) : process.cwd()
|
|
18
|
+
state.projectDir = source ? projectDirFor(resolve(source)) : process.cwd()
|
|
17
19
|
state.stats = values.stats
|
|
18
20
|
state.capture = values.capture ? resolve(values.capture) : undefined
|
|
19
21
|
|
|
@@ -29,13 +31,19 @@ export async function runServerCommand() {
|
|
|
29
31
|
if (initialResult) {
|
|
30
32
|
state.currentCode = initialResult.code
|
|
31
33
|
state.currentMap = initialResult.map
|
|
32
|
-
|
|
34
|
+
state.currentManifest = initialResult.manifest
|
|
35
|
+
await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), {
|
|
36
|
+
latch: true,
|
|
37
|
+
map: state.currentMap,
|
|
38
|
+
})
|
|
33
39
|
} else {
|
|
34
40
|
await showBuildFailure()
|
|
35
41
|
}
|
|
36
42
|
} else if (source && isPrebuilt && source.endsWith(".srt.js")) {
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
let path = resolve(source)
|
|
44
|
+
state.currentCode = await Bun.file(path).text()
|
|
45
|
+
state.currentManifest = buildManifest(state.currentCode, path)
|
|
46
|
+
await sendReload(buildReload({ code: state.currentCode, manifest: state.currentManifest }), { latch: true })
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
process.on("SIGINT", shutdown)
|
package/src/dev-client.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { state, print, requireBinary, pipeAbovePrompt, shutdown } from "./util"
|
|
2
2
|
import { DEV_HOST, DEV_PORT, getClients, shutdownWhenEmpty } from "./dev-server"
|
|
3
|
-
import { values } from "./args"
|
|
3
|
+
import { values, clientStorageArgs } from "./args"
|
|
4
4
|
|
|
5
5
|
export function spawnClient() {
|
|
6
6
|
let runner = requireBinary("solidrt-go")
|
|
7
7
|
// The local client and dev server share this machine, so connect straight to
|
|
8
8
|
// the loopback server: no mDNS discovery or recents lookup is needed for `run`.
|
|
9
|
-
let args: string[] = ["--dev-server", `${DEV_HOST}:${DEV_PORT}
|
|
9
|
+
let args: string[] = ["--dev-server", `${DEV_HOST}:${DEV_PORT}`, ...clientStorageArgs()]
|
|
10
10
|
if (values.size) args.push("--size", values.size)
|
|
11
11
|
state.child = Bun.spawn([runner, ...args], {
|
|
12
12
|
stdio: ["ignore", "pipe", "pipe"],
|
package/src/dev-server.ts
CHANGED
|
@@ -16,9 +16,18 @@ const INTERNAL_BASE = `http://${DEV_HOST}:${DEV_PORT}/__internal__`
|
|
|
16
16
|
|
|
17
17
|
// Build the reload message for the client protocol. The server latches a
|
|
18
18
|
// broadcast reload verbatim for late-joining clients, so srt owns the message
|
|
19
|
-
// shape (including the proxy flags) end to end.
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// shape (including the proxy flags) end to end. `manifest` is the bundle's
|
|
20
|
+
// version manifest JSON string; when present, clients install the push into
|
|
21
|
+
// their version store before applying it (absent for bytecode one-shots and
|
|
22
|
+
// the BSOD trigger, which must not be installed).
|
|
23
|
+
export function buildReload(payload: { code?: string | null; bytecode?: string; manifest?: string | null }) {
|
|
24
|
+
let { manifest, ...rest } = payload
|
|
25
|
+
return {
|
|
26
|
+
type: "reload",
|
|
27
|
+
proxyHttp: values["proxy-http"],
|
|
28
|
+
...(manifest ? { manifest } : {}),
|
|
29
|
+
...rest,
|
|
30
|
+
}
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
async function post(path: string, body: object) {
|
|
@@ -30,13 +39,21 @@ async function post(path: string, body: object) {
|
|
|
30
39
|
* Send a client-protocol message through the server: to the given client ids,
|
|
31
40
|
* or to every client when omitted. `latch` keeps the message for late-joining
|
|
32
41
|
* clients (code reloads latch, one-shot bytecode loads do not); `sourceDir`
|
|
33
|
-
* moves the server's file-serving root
|
|
34
|
-
* sourcemap, kept server-side for
|
|
35
|
-
* the server's map, so a mapless
|
|
42
|
+
* moves the server's file-serving root and `projectDir` its /assets/ root
|
|
43
|
+
* (repl `load`); `map` is the bundle's sourcemap, kept server-side for
|
|
44
|
+
* stack-trace remapping (omitting it clears the server's map, so a mapless
|
|
45
|
+
* reload never remaps against a stale one).
|
|
36
46
|
*/
|
|
37
47
|
export async function sendReload(
|
|
38
48
|
message: object,
|
|
39
|
-
opts: {
|
|
49
|
+
opts: {
|
|
50
|
+
clients?: number[]
|
|
51
|
+
latch?: boolean
|
|
52
|
+
sourceDir?: string
|
|
53
|
+
projectDir?: string
|
|
54
|
+
entry?: string
|
|
55
|
+
map?: string | null
|
|
56
|
+
} = {},
|
|
40
57
|
) {
|
|
41
58
|
await post("/reload", { message, ...opts })
|
|
42
59
|
}
|
|
@@ -97,6 +114,8 @@ const BSOD_TRIGGER = `throw new Error("SolidRT: build failed")`
|
|
|
97
114
|
// pushes it to every connected client.
|
|
98
115
|
export async function showBuildFailure() {
|
|
99
116
|
state.currentCode = BSOD_TRIGGER
|
|
117
|
+
// No manifest: the BSOD trigger is not a version and must never be installed.
|
|
118
|
+
state.currentManifest = null
|
|
100
119
|
await sendReload(buildReload({ code: BSOD_TRIGGER }), { latch: true })
|
|
101
120
|
}
|
|
102
121
|
|
|
@@ -158,14 +177,15 @@ export async function startServer() {
|
|
|
158
177
|
let config = {
|
|
159
178
|
port: DEV_PORT,
|
|
160
179
|
sourceDir: state.sourceDir,
|
|
180
|
+
projectDir: state.projectDir,
|
|
161
181
|
address,
|
|
162
|
-
proxyFiles: values["proxy-files"],
|
|
163
182
|
proxyHttp: values["proxy-http"],
|
|
164
183
|
entry: state.source,
|
|
165
184
|
minify: values.minify,
|
|
166
185
|
bundlerCmd: [process.execPath, bundleCli],
|
|
167
186
|
cache: values["proxy-http"],
|
|
168
|
-
cacheDir:
|
|
187
|
+
cacheDir: resolve(".srt-data"),
|
|
188
|
+
keyDir: process.cwd(),
|
|
169
189
|
capture: state.capture,
|
|
170
190
|
stats: state.stats,
|
|
171
191
|
tunnel: values.tunnel,
|
package/src/fonts.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { findProjectPackage } from "./project"
|
|
4
|
+
|
|
5
|
+
// The fonts `srt pack` appends to a solidrt binary (see
|
|
6
|
+
// okf/plans/packaged-fonts.md). By default the three Noto role defaults;
|
|
7
|
+
// the project's package.json can override them via the `solidrt.fonts` map
|
|
8
|
+
// (alias -> font file path, or false to drop a default):
|
|
9
|
+
//
|
|
10
|
+
// "solidrt": {
|
|
11
|
+
// "fonts": {
|
|
12
|
+
// "sans": "./fonts/Inter.ttf", // replaces the sans default
|
|
13
|
+
// "mono": false, // drops the mono default
|
|
14
|
+
// "display": "./fonts/F.ttf" // adds a font under a custom alias
|
|
15
|
+
// }
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
/** A resolved font source: the file behind an alias. */
|
|
19
|
+
export type ResolvedFont = { alias: string; path: string; isDefault: boolean }
|
|
20
|
+
|
|
21
|
+
let DEFAULT_FONTS: Record<string, string> = {
|
|
22
|
+
sans: "NotoSans.ttf",
|
|
23
|
+
serif: "NotoSerif.ttf",
|
|
24
|
+
mono: "NotoSansMono.ttf",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Where the default Noto files live: a contributor checkout via SRT_HOME, the
|
|
28
|
+
// fonts/ copy shipped inside the published CLI package (staged at release
|
|
29
|
+
// time), or the monorepo relative to this source file.
|
|
30
|
+
function defaultFontsDir(): string | null {
|
|
31
|
+
let candidates: string[] = []
|
|
32
|
+
if (process.env.SRT_HOME) candidates.push(resolve(process.env.SRT_HOME, "alloy/assets/fonts"))
|
|
33
|
+
candidates.push(resolve(import.meta.dir, "../fonts"))
|
|
34
|
+
candidates.push(resolve(import.meta.dir, "../../../alloy/assets/fonts"))
|
|
35
|
+
for (let dir of candidates) {
|
|
36
|
+
if (existsSync(resolve(dir, "NotoSans.ttf"))) return dir
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findProjectConfig(sourcePath: string): { dir: string; fonts: unknown } | null {
|
|
42
|
+
let project = findProjectPackage(sourcePath)
|
|
43
|
+
return project && { dir: project.dir, fonts: project.pkg.solidrt?.fonts }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(message: string): never {
|
|
47
|
+
console.error(message)
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve the font set for a pack as file paths: role defaults merged with the
|
|
52
|
+
// project's `solidrt.fonts` map. Order is roles first (sans, serif, mono),
|
|
53
|
+
// then added aliases in config order.
|
|
54
|
+
export function resolvePackFonts(sourcePath: string): ResolvedFont[] {
|
|
55
|
+
let config = findProjectConfig(sourcePath)
|
|
56
|
+
let overrides = config?.fonts ?? {}
|
|
57
|
+
if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
|
|
58
|
+
fail('The "solidrt.fonts" key in package.json must be a map of alias to font file path (or false)')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// alias -> path relative to the config dir, or null for a role default.
|
|
62
|
+
let selected = new Map<string, string | null>()
|
|
63
|
+
for (let role of Object.keys(DEFAULT_FONTS)) selected.set(role, null)
|
|
64
|
+
for (let [alias, value] of Object.entries(overrides)) {
|
|
65
|
+
if (value === false) {
|
|
66
|
+
if (!(alias in DEFAULT_FONTS)) fail(`"solidrt.fonts": "${alias}": false drops a default, but "${alias}" is not one of ${Object.keys(DEFAULT_FONTS).join("/")}`)
|
|
67
|
+
selected.delete(alias)
|
|
68
|
+
} else if (typeof value === "string") {
|
|
69
|
+
if (Buffer.byteLength(alias, "utf8") > 255) fail(`"solidrt.fonts": alias "${alias}" is too long (max 255 bytes)`)
|
|
70
|
+
selected.set(alias, resolve(config!.dir, value))
|
|
71
|
+
} else {
|
|
72
|
+
fail(`"solidrt.fonts": "${alias}" must be a font file path or false, got ${JSON.stringify(value)}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let defaultsDir: string | null = null
|
|
77
|
+
let fonts: ResolvedFont[] = []
|
|
78
|
+
for (let [alias, path] of selected) {
|
|
79
|
+
let isDefault = path === null
|
|
80
|
+
if (path === null) {
|
|
81
|
+
defaultsDir ??= defaultFontsDir() ?? fail(
|
|
82
|
+
"Could not find the default fonts (NotoSans.ttf and friends).\n" +
|
|
83
|
+
"Point SRT_HOME at your SolidRT checkout (and run `make download-fonts` there if needed).",
|
|
84
|
+
)
|
|
85
|
+
path = resolve(defaultsDir, DEFAULT_FONTS[alias]!)
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(path)) fail(`"solidrt.fonts": "${alias}": no such file: ${path}`)
|
|
88
|
+
fonts.push({ alias, path, isDefault })
|
|
89
|
+
}
|
|
90
|
+
return fonts
|
|
91
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
assetPathFor,
|
|
5
|
+
collectAssets,
|
|
6
|
+
loadAppIdentity,
|
|
7
|
+
projectDirFor,
|
|
8
|
+
RUNTIME_VERSION,
|
|
9
|
+
type ManifestFont,
|
|
10
|
+
} from "./project"
|
|
11
|
+
import { resolvePackFonts } from "./fonts"
|
|
12
|
+
|
|
13
|
+
// The canonical flat pack folder (okf/plans/client-storage-updates.md, Pack
|
|
14
|
+
// output): runner + manifest.json + bundle.bin + assets/. The manifest
|
|
15
|
+
// enumerates exactly the files belonging to the version - the runner is
|
|
16
|
+
// deliberately unlisted - and, unlike dev manifests, carries the full app
|
|
17
|
+
// identity (org, displayName) and the complete font set with the default
|
|
18
|
+
// fonts materialized under assets/fonts/.
|
|
19
|
+
|
|
20
|
+
function fail(message: string): never {
|
|
21
|
+
console.error(message)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hashHex(bytes: Uint8Array): string {
|
|
26
|
+
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PackFolder = {
|
|
30
|
+
/** The canonical manifest JSON string (serialized once, written verbatim). */
|
|
31
|
+
manifest: string
|
|
32
|
+
/** Files to place in the folder: absolute source -> folder-relative path. */
|
|
33
|
+
copies: Array<{ from: string; to: string }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildPackFolder(entry: string, bytecode: Buffer): PackFolder {
|
|
37
|
+
let identity = loadAppIdentity(entry)
|
|
38
|
+
let projectDir = projectDirFor(resolve(entry))
|
|
39
|
+
let { assets } = collectAssets(entry)
|
|
40
|
+
let copies = assets.map((a) => ({ from: join(projectDir, a.path), to: a.path }))
|
|
41
|
+
|
|
42
|
+
// The full resolved font set: custom fonts are already collected assets;
|
|
43
|
+
// defaults materialize under assets/fonts/ (a user file already at that
|
|
44
|
+
// path must be the same bytes, otherwise the layout is ambiguous).
|
|
45
|
+
let fonts: ManifestFont[] = []
|
|
46
|
+
for (let font of resolvePackFonts(entry)) {
|
|
47
|
+
if (font.isDefault) {
|
|
48
|
+
let path = "assets/fonts/" + basename(font.path)
|
|
49
|
+
let bytes = readFileSync(font.path)
|
|
50
|
+
let existing = assets.find((a) => a.path === path)
|
|
51
|
+
if (existing) {
|
|
52
|
+
if (existing.sha256 !== hashHex(bytes)) {
|
|
53
|
+
fail(`${path} collides with the packed default font; rename it or bind it via "solidrt.fonts"`)
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
assets.push({ path, sha256: hashHex(bytes), size: bytes.length })
|
|
57
|
+
copies.push({ from: font.path, to: path })
|
|
58
|
+
}
|
|
59
|
+
fonts.push({ path, alias: font.alias })
|
|
60
|
+
} else {
|
|
61
|
+
let path = assetPathFor(projectDir, font.path)
|
|
62
|
+
if (!path) fail(`"solidrt.fonts": "${font.alias}": ${font.path} must live under assets/`)
|
|
63
|
+
fonts.push({ path, alias: font.alias })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
assets.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
67
|
+
|
|
68
|
+
let manifest = JSON.stringify({
|
|
69
|
+
appId: identity.appId,
|
|
70
|
+
org: identity.org,
|
|
71
|
+
displayName: identity.displayName,
|
|
72
|
+
runtimeVersion: RUNTIME_VERSION,
|
|
73
|
+
bundle: { path: "bundle.bin", sha256: hashHex(bytecode), size: bytecode.length },
|
|
74
|
+
...(assets.length ? { assets } : {}),
|
|
75
|
+
...(fonts.length ? { fonts } : {}),
|
|
76
|
+
})
|
|
77
|
+
return { manifest, copies }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write the folder. An existing output dir is only reused when it is empty or
|
|
82
|
+
* already a pack folder (has a manifest.json) - then the files this pack owns
|
|
83
|
+
* (runner, manifest, bundle, assets/) are replaced; anything else in it is
|
|
84
|
+
* left alone but never a reason to touch an unrelated directory.
|
|
85
|
+
*/
|
|
86
|
+
export function writePackFolder(outDir: string, runnerPath: string, bytecode: Buffer, folder: PackFolder) {
|
|
87
|
+
let existing = existsSync(outDir) ? readdirSync(outDir) : null
|
|
88
|
+
if (existing && existing.length > 0 && !existing.includes("manifest.json")) {
|
|
89
|
+
fail(`${resolve(outDir)} exists and is not a pack folder; choose another --output`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let runnerName = "solidrt" + (process.platform === "win32" ? ".exe" : "")
|
|
93
|
+
mkdirSync(outDir, { recursive: true })
|
|
94
|
+
rmSync(join(outDir, "assets"), { recursive: true, force: true })
|
|
95
|
+
for (let name of ["manifest.json", "bundle.bin", runnerName]) {
|
|
96
|
+
rmSync(join(outDir, name), { force: true })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Dereference: the runner path may itself be a symlink (contributor dist
|
|
100
|
+
// layouts); the folder must carry the real binary.
|
|
101
|
+
cpSync(runnerPath, join(outDir, runnerName), { dereference: true })
|
|
102
|
+
if (process.platform !== "win32") {
|
|
103
|
+
Bun.spawnSync(["chmod", "+x", join(outDir, runnerName)])
|
|
104
|
+
}
|
|
105
|
+
writeFileSync(join(outDir, "bundle.bin"), bytecode)
|
|
106
|
+
writeFileSync(join(outDir, "manifest.json"), folder.manifest)
|
|
107
|
+
for (let { from, to } of folder.copies) {
|
|
108
|
+
let dest = join(outDir, to)
|
|
109
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
110
|
+
cpSync(from, dest)
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/packer.ts
CHANGED
|
@@ -1,27 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
1
2
|
import { requireBinary } from "./util"
|
|
2
3
|
import { compileToBytecode } from "./bundler"
|
|
4
|
+
import type { PackFolder } from "./pack-folder"
|
|
3
5
|
|
|
4
6
|
// Trailer magic identifying the runner an embedded payload belongs to. Must match
|
|
5
7
|
// the runner-side checks: fluxrt -> flux/src/bin/fluxrt.rs, solidrt ->
|
|
6
|
-
// lattice/src/main.rs (
|
|
8
|
+
// lattice/src/main.rs (load_embedded_payload).
|
|
7
9
|
const MAGIC = {
|
|
8
10
|
fluxrt: Buffer.from([0x46, 0x4c, 0x55, 0x58, 0x52, 0x54, 0x88, 0x44]), // "FLUXRT\x88\x44"
|
|
9
11
|
solidrt: Buffer.from([0x53, 0x4f, 0x4c, 0x49, 0x44, 0x52, 0x54, 0x88, 0x44]), // "SOLIDRT\x88\x44"
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
// Section kinds in the solidrt trailer. Must match lattice/src/main.rs.
|
|
15
|
+
const SECTION_MANIFEST = 1
|
|
16
|
+
const SECTION_FILE = 2
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
type Section = { kind: number; bytes: Buffer; name?: string }
|
|
19
|
+
|
|
20
|
+
// Append sections to the runner image: each section's bytes, then a table of
|
|
21
|
+
// section entries, then [table offset u64 LE][entry count u32 LE][magic].
|
|
22
|
+
// Table entry: [kind u32 LE][offset u64 LE][len u64 LE][name len u16 LE][name].
|
|
23
|
+
// Offsets are absolute file offsets. The runner reads its own image at
|
|
24
|
+
// startup, validates the magic, and slices the sections back out.
|
|
25
|
+
function packSections(runnerBytes: Buffer, sections: Section[], magic: Buffer): Buffer {
|
|
26
|
+
let parts: Buffer[] = [runnerBytes]
|
|
27
|
+
let entries: Buffer[] = []
|
|
28
|
+
let offset = runnerBytes.length
|
|
29
|
+
for (let section of sections) {
|
|
30
|
+
parts.push(section.bytes)
|
|
31
|
+
let name = Buffer.from(section.name ?? "", "utf8")
|
|
32
|
+
let entry = Buffer.allocUnsafe(22 + name.length)
|
|
33
|
+
entry.writeUInt32LE(section.kind, 0)
|
|
34
|
+
entry.writeBigUInt64LE(BigInt(offset), 4)
|
|
35
|
+
entry.writeBigUInt64LE(BigInt(section.bytes.length), 12)
|
|
36
|
+
entry.writeUInt16LE(name.length, 20)
|
|
37
|
+
name.copy(entry, 22)
|
|
38
|
+
entries.push(entry)
|
|
39
|
+
offset += section.bytes.length
|
|
40
|
+
}
|
|
41
|
+
let tail = Buffer.allocUnsafe(12)
|
|
42
|
+
tail.writeBigUInt64LE(BigInt(offset), 0) // the table starts where the sections end
|
|
43
|
+
tail.writeUInt32LE(sections.length, 8)
|
|
44
|
+
return Buffer.concat([...parts, ...entries, tail, magic])
|
|
45
|
+
}
|
|
19
46
|
|
|
20
|
-
|
|
21
|
-
|
|
47
|
+
// The single-file solidrt executable: the runner image plus the pack folder in
|
|
48
|
+
// section form - the canonical manifest verbatim, then every manifest-listed
|
|
49
|
+
// file named by its manifest path. Bundle, fonts, and identity all come from
|
|
50
|
+
// the manifest; assets are read in place via ranged reads at their section
|
|
51
|
+
// offsets, so nothing is unpacked at runtime.
|
|
52
|
+
export function packSolid(folder: PackFolder, bytecode: Buffer): Buffer {
|
|
53
|
+
let runnerBytes = readFileSync(requireBinary("solidrt"))
|
|
54
|
+
let sections: Section[] = [
|
|
55
|
+
{ kind: SECTION_MANIFEST, bytes: Buffer.from(folder.manifest, "utf8") },
|
|
56
|
+
{ kind: SECTION_FILE, bytes: bytecode, name: "bundle.bin" },
|
|
57
|
+
...folder.copies.map((c) => ({ kind: SECTION_FILE, bytes: readFileSync(c.from), name: c.to })),
|
|
58
|
+
]
|
|
59
|
+
return packSections(runnerBytes, sections, MAGIC.solidrt)
|
|
60
|
+
}
|
|
22
61
|
|
|
62
|
+
// Compile JS to bytecode and append it to the fluxrt runner as its
|
|
63
|
+
// single-payload trailer: [bytecode][u64 offset LE][8-byte magic].
|
|
64
|
+
export async function packFlux(jsCode: string): Promise<Buffer> {
|
|
65
|
+
let bytecode = await compileToBytecode(jsCode)
|
|
66
|
+
let runnerBytes = readFileSync(requireBinary("fluxrt"))
|
|
23
67
|
let offsetBuf = Buffer.allocUnsafe(8)
|
|
24
68
|
offsetBuf.writeBigUInt64LE(BigInt(runnerBytes.length))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
69
|
+
return Buffer.concat([runnerBytes, bytecode, offsetBuf, MAGIC.fluxrt])
|
|
70
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
3
|
+
|
|
4
|
+
// Project configuration lives in the `solidrt` key of the nearest package.json
|
|
5
|
+
// above the entry file (okf/plans/client-storage-updates.md):
|
|
6
|
+
//
|
|
7
|
+
// "solidrt": {
|
|
8
|
+
// "appId": "com.example.app", // stable identity: storage dir, Android package id
|
|
9
|
+
// "org": "Example", // optional display metadata (publisher)
|
|
10
|
+
// "displayName": "Example App", // optional display metadata (launcher/window)
|
|
11
|
+
// "fonts": { ... } // see fonts.ts
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// Everything defaults from the package name (or the entry filename when there
|
|
15
|
+
// is no package.json) so a dev project needs zero config; `srt pack` warns
|
|
16
|
+
// when appId is defaulted, since a distributed app should pin its identity.
|
|
17
|
+
|
|
18
|
+
export function findProjectPackage(sourcePath: string): { dir: string; pkg: any } | null {
|
|
19
|
+
let dir = resolve(dirname(sourcePath))
|
|
20
|
+
while (true) {
|
|
21
|
+
let pkgPath = resolve(dir, "package.json")
|
|
22
|
+
if (existsSync(pkgPath)) {
|
|
23
|
+
return { dir, pkg: JSON.parse(readFileSync(pkgPath, "utf8")) }
|
|
24
|
+
}
|
|
25
|
+
let parent = dirname(dir)
|
|
26
|
+
if (parent === dir) return null
|
|
27
|
+
dir = parent
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type AppIdentity = { appId: string; org: string; displayName: string; defaulted: boolean }
|
|
32
|
+
|
|
33
|
+
function fail(message: string): never {
|
|
34
|
+
console.error(message)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Storage directory component (matches the runtime's safe_component check).
|
|
39
|
+
let APP_ID_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
40
|
+
|
|
41
|
+
// Derived values are sanitized into a valid appId; explicit config must
|
|
42
|
+
// already be valid (throw-in-dev policy: a bad value fails the command).
|
|
43
|
+
function sanitizeAppId(name: string): string {
|
|
44
|
+
let id = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "")
|
|
45
|
+
return id === "" || id === "." || id === ".." ? "app" : id
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkField(value: string, what: string) {
|
|
49
|
+
if (Buffer.byteLength(value, "utf8") > 255) fail(`"solidrt": ${what} is too long (max 255 bytes)`)
|
|
50
|
+
if (/[/\\]/.test(value)) fail(`"solidrt": ${what} must not contain path separators`)
|
|
51
|
+
if (value === "") fail(`"solidrt": ${what} must not be empty`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The version manifest for a bundle (okf/plans/client-storage-updates.md,
|
|
55
|
+
// stages 2 + 3): appId + runtimeVersion + the bundle entry + the collected
|
|
56
|
+
// assets/ tree + font annotations. The returned JSON string is canonical - it
|
|
57
|
+
// travels verbatim to clients and its sha256 is the version id, so it must
|
|
58
|
+
// never be re-serialized along the way. runtimeVersion is the constant 1
|
|
59
|
+
// until the derivation question is settled (see plan).
|
|
60
|
+
// The manifest's runtimeVersion: a manually bumped constant until the
|
|
61
|
+
// derivation question is settled (see plan).
|
|
62
|
+
export const RUNTIME_VERSION = 1
|
|
63
|
+
|
|
64
|
+
export function buildManifest(code: string, entry: string): string {
|
|
65
|
+
let identity = loadAppIdentity(entry)
|
|
66
|
+
let sha256 = new Bun.CryptoHasher("sha256").update(code).digest("hex")
|
|
67
|
+
let { assets, fonts } = collectAssets(entry)
|
|
68
|
+
return JSON.stringify({
|
|
69
|
+
appId: identity.appId,
|
|
70
|
+
runtimeVersion: RUNTIME_VERSION,
|
|
71
|
+
bundle: { path: "bundle.js", sha256, size: Buffer.byteLength(code, "utf8") },
|
|
72
|
+
...(assets.length ? { assets } : {}),
|
|
73
|
+
...(fonts.length ? { fonts } : {}),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ManifestAsset = { path: string; sha256: string; size: number }
|
|
78
|
+
export type ManifestFont = { path: string; alias: string }
|
|
79
|
+
|
|
80
|
+
// The project root the assets/ convention hangs off: the nearest package.json
|
|
81
|
+
// dir, or the entry's own dir when there is none.
|
|
82
|
+
export function projectDirFor(sourcePath: string): string {
|
|
83
|
+
return findProjectPackage(sourcePath)?.dir ?? resolve(dirname(sourcePath))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The manifest asset path for an absolute file inside the project's assets/
|
|
87
|
+
// dir, or null when it lies outside it.
|
|
88
|
+
export function assetPathFor(projectDir: string, abs: string): string | null {
|
|
89
|
+
let rel = relative(resolve(projectDir, "assets"), abs)
|
|
90
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null
|
|
91
|
+
return "assets/" + rel.split(sep).join("/")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function walkAssets(assetsDir: string, dir: string, out: ManifestAsset[]) {
|
|
95
|
+
for (let entry of readdirSync(dir, { withFileTypes: true })) {
|
|
96
|
+
// Dotfiles (.DS_Store and friends) are tooling noise, not app assets.
|
|
97
|
+
if (entry.name.startsWith(".")) continue
|
|
98
|
+
let abs = join(dir, entry.name)
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
walkAssets(assetsDir, abs, out)
|
|
101
|
+
} else if (entry.isFile()) {
|
|
102
|
+
let bytes = readFileSync(abs)
|
|
103
|
+
let path = "assets/" + relative(assetsDir, abs).split(sep).join("/")
|
|
104
|
+
out.push({ path, sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), size: bytes.length })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The convention-first asset set: everything under the project's assets/
|
|
110
|
+
// folder (next to package.json), collected wholesale in sorted order so the
|
|
111
|
+
// manifest bytes are deterministic. Fonts are annotations pointing into that
|
|
112
|
+
// set: `solidrt.fonts` path entries must live under assets/ so they reach dev
|
|
113
|
+
// clients and the version store (`false` entries only drop pack defaults and
|
|
114
|
+
// have no manifest presence).
|
|
115
|
+
export function collectAssets(entry: string): { assets: ManifestAsset[]; fonts: ManifestFont[] } {
|
|
116
|
+
let project = findProjectPackage(entry)
|
|
117
|
+
let projectDir = projectDirFor(entry)
|
|
118
|
+
let assetsDir = resolve(projectDir, "assets")
|
|
119
|
+
|
|
120
|
+
let assets: ManifestAsset[] = []
|
|
121
|
+
if (existsSync(assetsDir) && statSync(assetsDir).isDirectory()) {
|
|
122
|
+
walkAssets(assetsDir, assetsDir, assets)
|
|
123
|
+
assets.sort((a, b) => (a.path < b.path ? -1 : 1))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let fonts: ManifestFont[] = []
|
|
127
|
+
let map = project?.pkg.solidrt?.fonts
|
|
128
|
+
if (map && typeof map === "object" && !Array.isArray(map)) {
|
|
129
|
+
for (let [alias, value] of Object.entries(map)) {
|
|
130
|
+
if (typeof value !== "string") continue
|
|
131
|
+
let path = assetPathFor(projectDir, resolve(projectDir, value))
|
|
132
|
+
if (!path) {
|
|
133
|
+
fail(`"solidrt.fonts": "${alias}": ${value} must live under assets/ (fonts ship as version assets)`)
|
|
134
|
+
}
|
|
135
|
+
if (!assets.some((a) => a.path === path)) {
|
|
136
|
+
fail(`"solidrt.fonts": "${alias}": no such file: ${resolve(projectDir, value)}`)
|
|
137
|
+
}
|
|
138
|
+
fonts.push({ path, alias })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { assets, fonts }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Resolve the app identity for a pack. All three fields are guaranteed
|
|
145
|
+
// non-empty and 255 bytes max (the trailer encoding's length prefix).
|
|
146
|
+
export function loadAppIdentity(sourcePath: string): AppIdentity {
|
|
147
|
+
let project = findProjectPackage(sourcePath)
|
|
148
|
+
let config = project?.pkg.solidrt ?? {}
|
|
149
|
+
let fallbackName = project?.pkg.name ?? basename(sourcePath).replace(/\.[jt]sx?$/, "")
|
|
150
|
+
|
|
151
|
+
for (let key of ["appId", "org", "displayName"]) {
|
|
152
|
+
if (key in config && typeof config[key] !== "string") fail(`"solidrt": "${key}" must be a string`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let appId: string
|
|
156
|
+
let defaulted = typeof config.appId !== "string"
|
|
157
|
+
if (defaulted) {
|
|
158
|
+
appId = sanitizeAppId(fallbackName)
|
|
159
|
+
} else {
|
|
160
|
+
appId = config.appId
|
|
161
|
+
if (!APP_ID_PATTERN.test(appId) || appId === "." || appId === "..") {
|
|
162
|
+
fail(`"solidrt": "appId" must match ${APP_ID_PATTERN} (reverse-DNS recommended, e.g. "com.example.app")`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let displayName = config.displayName ?? (project?.pkg.name || fallbackName)
|
|
166
|
+
let org = config.org ?? displayName
|
|
167
|
+
checkField(appId, '"appId"')
|
|
168
|
+
checkField(displayName, '"displayName"')
|
|
169
|
+
checkField(org, '"org"')
|
|
170
|
+
return { appId, org, displayName, defaulted }
|
|
171
|
+
}
|