neme-world 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/package.json +2 -5
  3. package/src/cli.js +73 -22
package/README.md CHANGED
@@ -43,9 +43,13 @@ npx neme-world publish # uploads your build and puts it live
43
43
 
44
44
  ## What publish does
45
45
 
46
- It zips `dir` and uploads it as a new version, which goes live at once. Hidden files (anything starting with a
47
- dot) and `node_modules` are never uploaded. Every version is kept, and you can make any earlier one live again
48
- from your dashboard.
46
+ It uploads `dir` file by file as a new version, which goes live at once. Only files that changed since your last
47
+ publish are sent, so updating a large game is quick. Hidden files (anything starting with a dot) and
48
+ `node_modules` are never uploaded. Every version is kept, and you can make any earlier one live again from your
49
+ dashboard.
50
+
51
+ A single file can be up to 95 MB, and a game up to 1 GB. One publish per game runs at a time: starting a second
52
+ replaces the first.
49
53
 
50
54
  ## Options
51
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neme-world",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Publish web games to neme.world from the command line",
5
5
  "keywords": ["neme", "games", "web games", "html5 games", "publish", "deploy", "cli"],
6
6
  "homepage": "https://neme.world/developers",
@@ -9,8 +9,5 @@
9
9
  "type": "module",
10
10
  "bin": { "neme": "src/cli.js" },
11
11
  "files": ["src/cli.js"],
12
- "engines": { "node": ">=20" },
13
- "dependencies": {
14
- "fflate": "^0.8.3"
15
- }
12
+ "engines": { "node": ">=20" }
16
13
  }
package/src/cli.js CHANGED
@@ -8,11 +8,11 @@
8
8
  *
9
9
  * Plain JavaScript on purpose: it runs from a checkout or from npm with no build.
10
10
  */
11
- import { realpathSync } from 'node:fs'
11
+ import { createHash } from 'node:crypto'
12
+ import { createReadStream, openAsBlob, realpathSync } from 'node:fs'
12
13
  import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
13
14
  import path from 'node:path'
14
15
  import { parseArgs } from 'node:util'
15
- import { zipSync } from 'fflate'
16
16
 
17
17
  const DEFAULT_API = 'https://neme.world'
18
18
  const SKIP_DIRS = new Set(['node_modules'])
@@ -56,25 +56,43 @@ export async function readManifest(dir) {
56
56
  }
57
57
 
58
58
  /**
59
- * Collects every publishable file under `root`. Dotfiles and node_modules are
60
- * left out so secrets and dependencies don't get uploaded by accident.
59
+ * @typedef {object} LocalFile
60
+ * @property {string} path Path inside the build, with forward slashes.
61
+ * @property {string} file Where it is on disk.
62
+ * @property {number} size
63
+ * @property {string} sha256
64
+ */
65
+
66
+ /** @param {string} file @returns {Promise<string>} */
67
+ function sha256Of(file) {
68
+ return new Promise((resolve, reject) => {
69
+ const hash = createHash('sha256')
70
+ createReadStream(file).on('data', (chunk) => hash.update(chunk)).on('error', reject).on('end', () => resolve(hash.digest('hex')))
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Lists every publishable file under `root`, with its size and hash. Dotfiles
76
+ * and node_modules are left out so secrets and dependencies don't get uploaded
77
+ * by accident. Files are hashed from disk, never held in memory whole.
61
78
  * @param {string} root
62
- * @returns {Promise<Record<string, Uint8Array>>}
79
+ * @returns {Promise<LocalFile[]>}
63
80
  */
64
81
  export async function collectFiles(root) {
65
- /** @type {Record<string, Uint8Array>} */
66
- const files = {}
82
+ /** @type {LocalFile[]} */
83
+ const files = []
67
84
  /** @param {string} rel */
68
85
  async function walk(rel) {
69
86
  for (const entry of await readdir(path.join(root, rel), { withFileTypes: true })) {
70
87
  if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue
71
88
  const child = rel ? `${rel}/${entry.name}` : entry.name
89
+ const file = path.join(root, child)
72
90
  if (entry.isDirectory()) await walk(child)
73
- else if (entry.isFile() && child !== 'neme.json') files[child] = await readFile(path.join(root, child))
91
+ else if (entry.isFile() && child !== 'neme.json') files.push({ path: child, file, size: (await stat(file)).size, sha256: await sha256Of(file) })
74
92
  }
75
93
  }
76
94
  await walk('')
77
- return files
95
+ return files.sort((a, b) => (a.path < b.path ? -1 : 1))
78
96
  }
79
97
 
80
98
  /**
@@ -82,7 +100,7 @@ export async function collectFiles(root) {
82
100
  * @param {string} token
83
101
  * @param {string} method
84
102
  * @param {string} route
85
- * @param {{ json?: unknown, body?: Uint8Array }} [o]
103
+ * @param {{ json?: unknown, body?: Blob }} [o]
86
104
  */
87
105
  async function call(api, token, method, route, o = {}) {
88
106
  /** @type {Record<string, string>} */
@@ -93,8 +111,8 @@ async function call(api, token, method, route, o = {}) {
93
111
  headers['content-type'] = 'application/json'
94
112
  body = JSON.stringify(o.json)
95
113
  } else if (o.body) {
96
- headers['content-type'] = 'application/zip'
97
- body = /** @type {BodyInit} */ (o.body)
114
+ headers['content-type'] = 'application/octet-stream'
115
+ body = o.body
98
116
  }
99
117
  let res
100
118
  try {
@@ -102,7 +120,7 @@ async function call(api, token, method, route, o = {}) {
102
120
  } catch (err) {
103
121
  throw new CliError(`Could not reach ${api}: ${/** @type {Error} */ (err).cause ?? /** @type {Error} */ (err).message}`)
104
122
  }
105
- const data = /** @type {any} */ (await res.json().catch(() => ({})))
123
+ const data = /** @type {any} */ (res.status === 204 ? {} : await res.json().catch(() => ({})))
106
124
  if (!res.ok) throw new CliError(`${data.error ?? `Request failed (${res.status})`}`)
107
125
  return data
108
126
  }
@@ -110,9 +128,14 @@ async function call(api, token, method, route, o = {}) {
110
128
  /** @param {number} n */
111
129
  const size = (n) => (n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
112
130
 
131
+ /** How many files are sent at once. */
132
+ const PARALLEL = 6
133
+
113
134
  /**
135
+ * Publishes file by file: declare everything, send only what the platform does
136
+ * not already have, then turn the upload into a version.
114
137
  * @param {{ dir: string, api: string, token: string, log?: (line: string) => void }} o
115
- * @returns {Promise<{ version: number, url: string, warnings: string[] }>}
138
+ * @returns {Promise<{ version: number, url: string, warnings: string[], uploaded: number }>}
116
139
  */
117
140
  export async function publish({ dir, api, token, log = console.log }) {
118
141
  const manifest = await readManifest(dir)
@@ -122,22 +145,50 @@ export async function publish({ dir, api, token, log = console.log }) {
122
145
  const info = await stat(buildDir).catch(() => null)
123
146
  if (!info?.isDirectory()) throw new CliError(`Build folder ${buildDir} does not exist. Did you run your build?`)
124
147
  const files = await collectFiles(buildDir)
125
- if (!files[entry]) throw new CliError(`${buildDir} has no ${entry}. Set "dir" in neme.json to your build output folder.`)
126
- if (manifest.cover && !files[manifest.cover]) throw new CliError(`Cover "${manifest.cover}" was not found in ${buildDir}.`)
148
+ if (!files.some((f) => f.path === entry)) throw new CliError(`${buildDir} has no ${entry}. Set "dir" in neme.json to your build output folder.`)
149
+ if (manifest.cover && !files.some((f) => f.path === manifest.cover)) throw new CliError(`Cover "${manifest.cover}" was not found in ${buildDir}.`)
127
150
 
128
- const count = Object.keys(files).length
129
- const zip = zipSync(files, { level: 6 })
130
- log(`${manifest.title}: ${count} file${count === 1 ? '' : 's'}, ${size(zip.length)} zipped`)
151
+ const total = files.reduce((n, f) => n + f.size, 0)
152
+ log(`${manifest.title}: ${files.length} file${files.length === 1 ? '' : 's'}, ${size(total)}`)
131
153
 
132
154
  const { slug, dir: _dir, entry: _entry, ...details } = manifest
133
155
  await call(api, token, 'PUT', `/games/${slug}`, { json: details })
134
- const out = await call(api, token, 'POST', `/games/${slug}/versions?entry=${encodeURIComponent(entry)}`, { body: zip })
156
+ const begun = await call(api, token, 'POST', `/games/${slug}/uploads`, {
157
+ json: { entry, files: files.map(({ path, size, sha256 }) => ({ path, size, sha256 })) },
158
+ })
159
+
160
+ /** @type {{ sha256: string, size: number }[]} */
161
+ const missing = begun.missing
162
+ const byHash = new Map(files.map((f) => [f.sha256, f]))
163
+ const sending = missing.reduce((n, m) => n + m.size, 0)
164
+ if (missing.length) log(` uploading ${missing.length} file${missing.length === 1 ? '' : 's'} (${size(sending)}); ${files.length - missing.length} already there`)
165
+ else log(' nothing new to upload')
166
+
167
+ const queue = [...missing]
168
+ const send = async () => {
169
+ for (let next = queue.pop(); next; next = queue.pop()) {
170
+ const local = /** @type {LocalFile} */ (byHash.get(next.sha256))
171
+ const route = `/games/${slug}/uploads/${begun.upload}/blobs/${next.sha256}`
172
+ try {
173
+ await call(api, token, 'PUT', route, { body: await openAsBlob(local.file) })
174
+ } catch (err) {
175
+ // One retry covers a dropped connection; anything else is reported with the file's name.
176
+ try {
177
+ await call(api, token, 'PUT', route, { body: await openAsBlob(local.file) })
178
+ } catch (again) {
179
+ throw new CliError(`Could not upload ${local.path}: ${/** @type {Error} */ (again).message}`)
180
+ }
181
+ }
182
+ }
183
+ }
184
+ await Promise.all(Array.from({ length: PARALLEL }, send))
135
185
 
136
- for (const w of out.warnings ?? []) log(` warning: ${w}`)
186
+ const out = await call(api, token, 'POST', `/games/${slug}/uploads/${begun.upload}/complete`, { json: {} })
187
+ for (const w of [...(begun.warnings ?? []), ...(out.warnings ?? [])]) log(` warning: ${w}`)
137
188
  const saved = out.transferBytes < out.bytes ? ` (${size(out.bytes)} before compression)` : ''
138
189
  log(` v${out.version} is live — ${size(out.transferBytes)} to download${saved}`)
139
190
  log(` ${out.game.url}`)
140
- return { version: out.version, url: out.game.url, warnings: out.warnings ?? [] }
191
+ return { version: out.version, url: out.game.url, warnings: out.warnings ?? [], uploaded: missing.length }
141
192
  }
142
193
 
143
194
  /** @param {string} dir */