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.
- package/README.md +7 -3
- package/package.json +2 -5
- 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
|
|
47
|
-
|
|
48
|
-
from your
|
|
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.
|
|
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 {
|
|
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
|
-
*
|
|
60
|
-
*
|
|
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<
|
|
79
|
+
* @returns {Promise<LocalFile[]>}
|
|
63
80
|
*/
|
|
64
81
|
export async function collectFiles(root) {
|
|
65
|
-
/** @type {
|
|
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
|
|
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?:
|
|
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/
|
|
97
|
-
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
|
|
126
|
-
if (manifest.cover && !files
|
|
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
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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 */
|