@solidrt/cli 0.0.30 → 0.0.33

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.
@@ -1,6 +1,11 @@
1
1
  import { values, source } from "../args"
2
- import { bundleFlux, bundleSolid } from "../bundler"
3
- import { packRunner } from "../packer"
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
- let packed = values.flux
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
+ }
@@ -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
- await sendReload(buildReload({ code: state.currentCode }), { latch: true, map: state.currentMap })
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
- state.currentCode = await Bun.file(resolve(source)).text()
38
- await sendReload(buildReload({ code: state.currentCode }), { latch: true })
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
- export function buildReload(payload: { code?: string | null; bytecode?: string }) {
21
- return { type: "reload", proxyFiles: values["proxy-files"], proxyHttp: values["proxy-http"], ...payload }
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 (repl `load`); `map` is the bundle's
34
- * sourcemap, kept server-side for stack-trace remapping (omitting it clears
35
- * the server's map, so a mapless reload never remaps against a stale one).
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: { clients?: number[]; latch?: boolean; sourceDir?: string; entry?: string; map?: string | null } = {},
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: process.cwd(),
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 (load_embedded_bytecode).
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
- export type Runner = keyof typeof MAGIC
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
- // Compile JS to bytecode and append it to the runner binary, followed by a
15
- // trailer of [u64 offset LE][8-byte magic]. The runner reads its own image at
16
- // startup, validates the magic, and slices the bytecode back out.
17
- export async function packRunner(runner: Runner, jsCode: string): Promise<Buffer> {
18
- let bytecode = await compileToBytecode(jsCode)
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
- let runnerPath = requireBinary(runner)
21
- let runnerBytes = Buffer.from(await Bun.file(runnerPath).arrayBuffer())
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
- return Buffer.concat([runnerBytes, bytecode, offsetBuf, MAGIC[runner]])
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
+ }