@arcote.tech/arc-cli 0.7.0 → 0.7.2
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/dist/index.js +1410 -1339
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +10 -24
- package/src/builder/module-builder.ts +343 -160
- package/src/commands/platform-build.ts +2 -1
- package/src/commands/platform-deploy.ts +30 -21
- package/src/deploy/ansible.ts +23 -3
- package/src/deploy/assets/ansible/site.yml +23 -7
- package/src/deploy/assets.ts +23 -7
- package/src/deploy/bootstrap.ts +137 -28
- package/src/deploy/compose.ts +4 -3
- package/src/deploy/config.ts +38 -3
- package/src/deploy/deploy-env.ts +1 -1
- package/src/deploy/env-file.ts +103 -0
- package/src/deploy/image.ts +7 -1
- package/src/deploy/ssh.ts +51 -2
- package/src/index.ts +5 -0
- package/src/platform/server.ts +99 -99
- package/src/platform/shared.ts +28 -240
- package/src/platform/startup.ts +4 -5
package/src/deploy/image.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function buildImage(
|
|
|
55
55
|
): Promise<BuildImageResult> {
|
|
56
56
|
await ensureDocker();
|
|
57
57
|
|
|
58
|
-
const manifestPath = join(ws.
|
|
58
|
+
const manifestPath = join(ws.arcDir, "manifest.json");
|
|
59
59
|
if (!existsSync(manifestPath)) {
|
|
60
60
|
throw new Error(
|
|
61
61
|
`No build manifest at ${manifestPath}. Run \`arc platform build\` first or omit --skip-build.`,
|
|
@@ -89,8 +89,14 @@ export async function buildImage(
|
|
|
89
89
|
const dockerfilePath = join(dockerfileDir, "Dockerfile");
|
|
90
90
|
writeFileSync(dockerfilePath, dockerfile);
|
|
91
91
|
|
|
92
|
+
// Always build linux/amd64. Most production servers (Hetzner cpx*, AWS x86,
|
|
93
|
+
// DigitalOcean, etc.) are x86_64. Without --platform, buildx defaults to
|
|
94
|
+
// the host arch — Apple Silicon devs produce arm64 images that fail with
|
|
95
|
+
// `exec format error` on AMD hosts. Cross-arch QEMU emulation is slow but
|
|
96
|
+
// reliable; we accept the tradeoff for portability.
|
|
92
97
|
const buildArgs = [
|
|
93
98
|
"build",
|
|
99
|
+
"--platform=linux/amd64",
|
|
94
100
|
"-f",
|
|
95
101
|
dockerfilePath,
|
|
96
102
|
"-t",
|
package/src/deploy/ssh.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { spawn } from "bun";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
2
5
|
import type { DeployTarget } from "./config";
|
|
3
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Pick a private SSH key to authenticate with. Honors target.sshKey if set,
|
|
9
|
+
* otherwise tries the standard candidates in ~/.ssh/. Returns null if no
|
|
10
|
+
* usable key is found — caller can then fall back to default ssh-agent
|
|
11
|
+
* behavior (i.e. omit IdentitiesOnly).
|
|
12
|
+
*/
|
|
13
|
+
function pickSshKey(target: DeployTarget): string | null {
|
|
14
|
+
if (target.sshKey) {
|
|
15
|
+
const expanded = target.sshKey.startsWith("~")
|
|
16
|
+
? join(homedir(), target.sshKey.slice(1))
|
|
17
|
+
: target.sshKey;
|
|
18
|
+
return existsSync(expanded) ? expanded : null;
|
|
19
|
+
}
|
|
20
|
+
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
|
|
21
|
+
const path = join(homedir(), ".ssh", name);
|
|
22
|
+
if (existsSync(path)) return path;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
/** Convert a Bun subprocess stream (which may be a ReadableStream or undefined) to string. */
|
|
5
28
|
async function streamToString(
|
|
6
29
|
stream: ReadableStream<Uint8Array> | number | undefined,
|
|
@@ -21,15 +44,34 @@ export interface SshExecResult {
|
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
export function baseSshArgs(target: DeployTarget): string[] {
|
|
47
|
+
// Pin to a single identity to avoid "Too many authentication failures":
|
|
48
|
+
// the server's MaxAuthTries=3 (set by ansible) trips when ssh-agent has
|
|
49
|
+
// more than 3 keys loaded and walks all of them before reaching ours.
|
|
50
|
+
// PreferredAuthentications=publickey skips gssapi/keyboard prompts entirely.
|
|
51
|
+
const key = pickSshKey(target);
|
|
24
52
|
const args = [
|
|
25
53
|
"-o",
|
|
26
54
|
"BatchMode=yes",
|
|
27
55
|
"-o",
|
|
28
56
|
"StrictHostKeyChecking=accept-new",
|
|
57
|
+
"-o",
|
|
58
|
+
"PreferredAuthentications=publickey",
|
|
59
|
+
// Fail fast: TCP-level connect attempts give up after 5s instead of
|
|
60
|
+
// hanging on the default ~75s when ufw drops, fail2ban bans, or the
|
|
61
|
+
// VM is briefly unreachable. ServerAlive* keeps an established
|
|
62
|
+
// connection from silently stalling on a dead route.
|
|
63
|
+
"-o",
|
|
64
|
+
"ConnectTimeout=5",
|
|
65
|
+
"-o",
|
|
66
|
+
"ServerAliveInterval=10",
|
|
67
|
+
"-o",
|
|
68
|
+
"ServerAliveCountMax=2",
|
|
29
69
|
"-p",
|
|
30
70
|
String(target.port),
|
|
31
71
|
];
|
|
32
|
-
if (
|
|
72
|
+
if (key) {
|
|
73
|
+
args.push("-o", "IdentitiesOnly=yes", "-i", key);
|
|
74
|
+
}
|
|
33
75
|
return args;
|
|
34
76
|
}
|
|
35
77
|
|
|
@@ -115,15 +157,22 @@ export async function scpUpload(
|
|
|
115
157
|
localPath: string,
|
|
116
158
|
remotePath: string,
|
|
117
159
|
): Promise<void> {
|
|
160
|
+
const key = pickSshKey(target);
|
|
118
161
|
const args = [
|
|
119
162
|
"-o",
|
|
120
163
|
"BatchMode=yes",
|
|
121
164
|
"-o",
|
|
122
165
|
"StrictHostKeyChecking=accept-new",
|
|
166
|
+
"-o",
|
|
167
|
+
"PreferredAuthentications=publickey",
|
|
168
|
+
"-o",
|
|
169
|
+
"ConnectTimeout=5",
|
|
123
170
|
"-P",
|
|
124
171
|
String(target.port),
|
|
125
172
|
];
|
|
126
|
-
if (
|
|
173
|
+
if (key) {
|
|
174
|
+
args.push("-o", "IdentitiesOnly=yes", "-i", key);
|
|
175
|
+
}
|
|
127
176
|
args.push(localPath, `${target.user}@${target.host}:${remotePath}`);
|
|
128
177
|
|
|
129
178
|
const proc = spawn({ cmd: ["scp", ...args], stderr: "pipe" });
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,10 @@ platform
|
|
|
63
63
|
"--image-tag <hash>",
|
|
64
64
|
"Roll back / pin to an existing image tag instead of building a new one",
|
|
65
65
|
)
|
|
66
|
+
.option(
|
|
67
|
+
"--force-bootstrap",
|
|
68
|
+
"Re-run Ansible host bootstrap even if the server is already configured",
|
|
69
|
+
)
|
|
66
70
|
.action(
|
|
67
71
|
(
|
|
68
72
|
env: string | undefined,
|
|
@@ -71,6 +75,7 @@ platform
|
|
|
71
75
|
rebuild?: boolean;
|
|
72
76
|
buildOnly?: boolean;
|
|
73
77
|
imageTag?: string;
|
|
78
|
+
forceBootstrap?: boolean;
|
|
74
79
|
},
|
|
75
80
|
) => platformDeploy(env, opts),
|
|
76
81
|
);
|
package/src/platform/server.ts
CHANGED
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
import { existsSync, mkdirSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { readTranslationsConfig } from "../i18n";
|
|
14
|
-
import type { BuildManifest,
|
|
15
|
-
import type { ModuleAccess } from "@arcote.tech/platform";
|
|
14
|
+
import type { BuildManifest, WorkspaceInfo } from "./shared";
|
|
15
|
+
import type { BuildManifestGroup, ModuleAccess } from "@arcote.tech/platform";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Types
|
|
@@ -30,8 +30,6 @@ export interface PlatformServerOptions {
|
|
|
30
30
|
dbPath?: string;
|
|
31
31
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
32
32
|
devMode?: boolean;
|
|
33
|
-
/** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
|
|
34
|
-
arcEntries?: [string, string][];
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
export interface PlatformServer {
|
|
@@ -74,25 +72,15 @@ export async function initContextHandler(
|
|
|
74
72
|
export function generateShellHtml(
|
|
75
73
|
appName: string,
|
|
76
74
|
manifest?: { title: string; favicon?: string },
|
|
77
|
-
|
|
75
|
+
initial?: { file: string; hash: string },
|
|
78
76
|
): string {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
// Initial bundle carries framework, public modules, and PlatformApp re-export.
|
|
78
|
+
// No importmap — single Bun.build with splitting:true inlines + dedups everything
|
|
79
|
+
// across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
|
|
80
|
+
const initialUrl = initial ? `/browser/${initial.file}` : null;
|
|
81
|
+
if (!initialUrl) {
|
|
82
|
+
throw new Error("generateShellHtml: initial bundle missing from manifest");
|
|
84
83
|
}
|
|
85
|
-
const importMap = {
|
|
86
|
-
imports: {
|
|
87
|
-
react: "/shell/react.js",
|
|
88
|
-
"react/jsx-runtime": "/shell/jsx-runtime.js",
|
|
89
|
-
"react/jsx-dev-runtime": "/shell/jsx-dev-runtime.js",
|
|
90
|
-
"react-dom": "/shell/react-dom.js",
|
|
91
|
-
"react-dom/client": "/shell/react-dom-client.js",
|
|
92
|
-
...arcImports,
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
|
|
96
84
|
return `<!doctype html>
|
|
97
85
|
<html lang="en">
|
|
98
86
|
<head>
|
|
@@ -101,18 +89,13 @@ export function generateShellHtml(
|
|
|
101
89
|
<title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json">` : ""}
|
|
102
90
|
<link rel="stylesheet" href="/styles.css" />
|
|
103
91
|
<link rel="stylesheet" href="/theme.css" />
|
|
104
|
-
<
|
|
92
|
+
<link rel="modulepreload" href="${initialUrl}" />
|
|
105
93
|
</head>
|
|
106
94
|
<body>
|
|
107
95
|
<div id="root"></div>
|
|
108
96
|
<script type="module">
|
|
109
|
-
import {
|
|
110
|
-
|
|
111
|
-
import { PlatformApp } from "@arcote.tech/platform";
|
|
112
|
-
|
|
113
|
-
createRoot(document.getElementById("root")).render(
|
|
114
|
-
createElement(PlatformApp)
|
|
115
|
-
);
|
|
97
|
+
import { startApp } from "${initialUrl}";
|
|
98
|
+
startApp("root");
|
|
116
99
|
</script>
|
|
117
100
|
</body>
|
|
118
101
|
</html>`;
|
|
@@ -160,25 +143,41 @@ function serveFile(
|
|
|
160
143
|
// Module access — signed URLs
|
|
161
144
|
// ---------------------------------------------------------------------------
|
|
162
145
|
|
|
163
|
-
|
|
146
|
+
// Secret is initialized lazily by `startPlatformServer` so dev mode can derive
|
|
147
|
+
// a stable, workspace-scoped value (browser sessions survive a watcher restart;
|
|
148
|
+
// without this, every restart invalidates every signed URL in the open page).
|
|
149
|
+
// Prod uses ARC_MODULE_SECRET if set, otherwise a random UUID per process.
|
|
150
|
+
let MODULE_SIG_SECRET: string = process.env.ARC_MODULE_SECRET ?? "";
|
|
164
151
|
const MODULE_SIG_TTL = 3600; // 1 hour
|
|
165
152
|
|
|
153
|
+
function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
|
|
154
|
+
if (MODULE_SIG_SECRET) return;
|
|
155
|
+
if (devMode) {
|
|
156
|
+
// Deterministic per workspace — restart-safe in dev.
|
|
157
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
158
|
+
hasher.update(`arc-dev-secret:${ws.rootDir}`);
|
|
159
|
+
MODULE_SIG_SECRET = hasher.digest("hex").slice(0, 32);
|
|
160
|
+
} else {
|
|
161
|
+
MODULE_SIG_SECRET = crypto.randomUUID();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
166
165
|
/**
|
|
167
|
-
* Signed URL for a
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
166
|
+
* Signed URL for a token-group bundle. HMAC payload binds the filename so
|
|
167
|
+
* a sig minted for `/browser/admin.<hash>.js` cannot be replayed for any
|
|
168
|
+
* other file. Shared chunks (chunk-<hash>.js) are NEVER signed — their
|
|
169
|
+
* filenames are content-hashed and they don't carry private code on their
|
|
170
|
+
* own (group entries side-effect-register the modules).
|
|
171
171
|
*/
|
|
172
|
-
function
|
|
172
|
+
function signGroupUrl(file: string): string {
|
|
173
173
|
const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
|
|
174
174
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
175
|
-
hasher.update(`${
|
|
175
|
+
hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
176
176
|
const sig = hasher.digest("hex").slice(0, 16);
|
|
177
|
-
return `/
|
|
177
|
+
return `/browser/${file}?sig=${sig}&exp=${exp}`;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
function
|
|
181
|
-
chunk: string,
|
|
180
|
+
function verifyGroupSignature(
|
|
182
181
|
file: string,
|
|
183
182
|
sig: string | null,
|
|
184
183
|
exp: string | null,
|
|
@@ -186,7 +185,7 @@ function verifyChunkSignature(
|
|
|
186
185
|
if (!sig || !exp) return false;
|
|
187
186
|
if (Number(exp) < Date.now() / 1000) return false;
|
|
188
187
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
189
|
-
hasher.update(`${
|
|
188
|
+
hasher.update(`${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
190
189
|
return hasher.digest("hex").slice(0, 16) === sig;
|
|
191
190
|
}
|
|
192
191
|
|
|
@@ -223,59 +222,59 @@ function parseArcTokensHeader(header: string | null): any[] {
|
|
|
223
222
|
}
|
|
224
223
|
|
|
225
224
|
/**
|
|
226
|
-
* Filter manifest to
|
|
227
|
-
*
|
|
228
|
-
*
|
|
225
|
+
* Filter manifest groups to those the caller's tokens grant access to. A
|
|
226
|
+
* group is unlocked when (a) the caller carries a token whose `tokenType`
|
|
227
|
+
* matches the group name, and (b) every per-module rule declared via
|
|
228
|
+
* `protectedBy(token, check)` passes for at least one of those tokens.
|
|
229
229
|
*
|
|
230
|
-
*
|
|
231
|
-
* with a signed URL (
|
|
232
|
-
*
|
|
230
|
+
* Returns a manifest with `groups` filtered + each remaining entry's `url`
|
|
231
|
+
* filled with a signed URL (HMAC, 1h TTL). `initial` + `sharedChunks` ride
|
|
232
|
+
* along unchanged — those are public/content-addressed.
|
|
233
233
|
*/
|
|
234
234
|
async function filterManifestForTokens(
|
|
235
235
|
manifest: BuildManifest,
|
|
236
236
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
237
237
|
tokenPayloads: any[],
|
|
238
238
|
): Promise<BuildManifest> {
|
|
239
|
-
const
|
|
239
|
+
const allowedGroups = new Set<string>();
|
|
240
240
|
for (const t of tokenPayloads) {
|
|
241
|
-
if (t?.tokenType)
|
|
241
|
+
if (t?.tokenType) allowedGroups.add(t.tokenType);
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
const
|
|
244
|
+
const filteredGroups: Record<string, BuildManifestGroup> = {};
|
|
245
245
|
|
|
246
|
-
for (const
|
|
247
|
-
if (!
|
|
246
|
+
for (const [name, group] of Object.entries(manifest.groups)) {
|
|
247
|
+
if (!allowedGroups.has(name)) continue;
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let granted = true;
|
|
257
|
-
if (access && access.rules.length > 0) {
|
|
258
|
-
granted = false;
|
|
249
|
+
// Every module in the group must pass its per-rule check (if any).
|
|
250
|
+
// If all rules pass (or none declared), unlock the group bundle.
|
|
251
|
+
let allGranted = true;
|
|
252
|
+
for (const moduleName of group.modules) {
|
|
253
|
+
const access = moduleAccessMap.get(moduleName);
|
|
254
|
+
if (!access || access.rules.length === 0) continue;
|
|
255
|
+
let granted = false;
|
|
259
256
|
for (const rule of access.rules) {
|
|
260
|
-
if (rule.token.name !==
|
|
261
|
-
const matching = tokenPayloads.find(
|
|
262
|
-
(t) => t.tokenType === rule.token.name,
|
|
263
|
-
);
|
|
257
|
+
if (rule.token.name !== name) continue;
|
|
258
|
+
const matching = tokenPayloads.find((t) => t.tokenType === name);
|
|
264
259
|
if (!matching) continue;
|
|
265
260
|
granted = rule.check ? await rule.check(matching) : true;
|
|
266
261
|
if (granted) break;
|
|
267
262
|
}
|
|
263
|
+
if (!granted) {
|
|
264
|
+
allGranted = false;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
268
267
|
}
|
|
269
268
|
|
|
270
|
-
if (
|
|
271
|
-
|
|
269
|
+
if (allGranted) {
|
|
270
|
+
filteredGroups[name] = { ...group, url: signGroupUrl(group.file) };
|
|
272
271
|
}
|
|
273
272
|
}
|
|
274
273
|
|
|
275
274
|
return {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
275
|
+
initial: manifest.initial,
|
|
276
|
+
groups: filteredGroups,
|
|
277
|
+
sharedChunks: manifest.sharedChunks,
|
|
279
278
|
stylesHash: manifest.stylesHash,
|
|
280
279
|
buildTime: manifest.buildTime,
|
|
281
280
|
};
|
|
@@ -285,43 +284,40 @@ async function filterManifestForTokens(
|
|
|
285
284
|
// Platform-specific HTTP handlers
|
|
286
285
|
// ---------------------------------------------------------------------------
|
|
287
286
|
|
|
288
|
-
/**
|
|
289
|
-
*
|
|
290
|
-
const
|
|
291
|
-
/** Module filenames are Bun.build outputs — `<safeName>.js` or `chunk-<hash>.js`. */
|
|
292
|
-
const MODULE_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
|
|
287
|
+
/** Browser bundle filenames: `initial.<hash>.js`, `<tokenName>.<hash>.js`,
|
|
288
|
+
* `chunk-<hash>.js`. All Bun.build outputs with content-addressed hashes. */
|
|
289
|
+
const BROWSER_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
|
|
293
290
|
|
|
294
291
|
function staticFilesHandler(
|
|
295
292
|
ws: WorkspaceInfo,
|
|
296
293
|
devMode: boolean,
|
|
294
|
+
getManifest: () => BuildManifest,
|
|
297
295
|
): ArcHttpHandler {
|
|
298
296
|
return (_req, url, ctx) => {
|
|
299
297
|
const path = url.pathname;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (path.startsWith("/
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const slash = rest.indexOf("/");
|
|
306
|
-
if (slash <= 0) {
|
|
298
|
+
// Single flat browser dir: initial bundle, per-token group entries,
|
|
299
|
+
// and auto-emitted shared chunks all live in <arcDir>/browser/.
|
|
300
|
+
if (path.startsWith("/browser/")) {
|
|
301
|
+
const file = path.slice(9);
|
|
302
|
+
if (!BROWSER_FILE_RE.test(file)) {
|
|
307
303
|
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
308
304
|
}
|
|
309
|
-
const chunk = rest.slice(0, slash);
|
|
310
|
-
const file = rest.slice(slash + 1);
|
|
311
305
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
306
|
+
// Only token-group entries are signed. The initial bundle and shared
|
|
307
|
+
// chunks are content-addressed (filename = hash) — unsigned by design.
|
|
308
|
+
const manifest = getManifest();
|
|
309
|
+
const isGroupEntry = Object.values(manifest.groups).some(
|
|
310
|
+
(g) => g.file === file,
|
|
311
|
+
);
|
|
312
|
+
if (isGroupEntry) {
|
|
317
313
|
const sig = url.searchParams.get("sig");
|
|
318
314
|
const exp = url.searchParams.get("exp");
|
|
319
|
-
if (!
|
|
315
|
+
if (!verifyGroupSignature(file, sig, exp)) {
|
|
320
316
|
return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
|
|
321
317
|
}
|
|
322
318
|
}
|
|
323
319
|
|
|
324
|
-
return serveFile(join(ws.
|
|
320
|
+
return serveFile(join(ws.browserDir, file), {
|
|
325
321
|
...ctx.corsHeaders,
|
|
326
322
|
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
327
323
|
});
|
|
@@ -381,7 +377,7 @@ function apiEndpointsHandler(
|
|
|
381
377
|
return Response.json(
|
|
382
378
|
{
|
|
383
379
|
status: "ok",
|
|
384
|
-
|
|
380
|
+
groups: Object.keys(getManifest().groups).length,
|
|
385
381
|
clients: cm?.clientCount ?? 0,
|
|
386
382
|
},
|
|
387
383
|
{ headers: ctx.corsHeaders },
|
|
@@ -419,9 +415,9 @@ function devReloadHandler(
|
|
|
419
415
|
};
|
|
420
416
|
}
|
|
421
417
|
|
|
422
|
-
function spaFallbackHandler(
|
|
418
|
+
function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
|
|
423
419
|
return (_req, _url, ctx) => {
|
|
424
|
-
return new Response(
|
|
420
|
+
return new Response(getShellHtml(), {
|
|
425
421
|
headers: { ...ctx.corsHeaders, "Content-Type": "text/html" },
|
|
426
422
|
});
|
|
427
423
|
};
|
|
@@ -435,6 +431,7 @@ export async function startPlatformServer(
|
|
|
435
431
|
opts: PlatformServerOptions,
|
|
436
432
|
): Promise<PlatformServer> {
|
|
437
433
|
const { ws, port, devMode, context } = opts;
|
|
434
|
+
ensureModuleSigSecret(ws, !!devMode);
|
|
438
435
|
const moduleAccessMap = opts.moduleAccess ?? new Map();
|
|
439
436
|
let manifest = opts.manifest;
|
|
440
437
|
const getManifest = () => manifest;
|
|
@@ -442,7 +439,10 @@ export async function startPlatformServer(
|
|
|
442
439
|
manifest = m;
|
|
443
440
|
};
|
|
444
441
|
|
|
445
|
-
|
|
442
|
+
// Recompute on every request — manifest.initial.hash changes when public
|
|
443
|
+
// modules are rebuilt in dev, and we want the new URL in the HTML.
|
|
444
|
+
const getShellHtml = (): string =>
|
|
445
|
+
generateShellHtml(ws.appName, ws.manifest, manifest?.initial);
|
|
446
446
|
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
447
447
|
|
|
448
448
|
const notifyReload = (m: BuildManifest) => {
|
|
@@ -487,8 +487,8 @@ export async function startPlatformServer(
|
|
|
487
487
|
const handlers: ArcHttpHandler[] = [
|
|
488
488
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
489
489
|
devReloadHandler(sseClients),
|
|
490
|
-
staticFilesHandler(ws, !!devMode),
|
|
491
|
-
spaFallbackHandler(
|
|
490
|
+
staticFilesHandler(ws, !!devMode, getManifest),
|
|
491
|
+
spaFallbackHandler(getShellHtml),
|
|
492
492
|
];
|
|
493
493
|
|
|
494
494
|
for (const handler of handlers) {
|
|
@@ -526,8 +526,8 @@ export async function startPlatformServer(
|
|
|
526
526
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
527
527
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
528
528
|
devReloadHandler(sseClients),
|
|
529
|
-
staticFilesHandler(ws, !!devMode),
|
|
530
|
-
spaFallbackHandler(
|
|
529
|
+
staticFilesHandler(ws, !!devMode, getManifest),
|
|
530
|
+
spaFallbackHandler(getShellHtml),
|
|
531
531
|
],
|
|
532
532
|
onWsClose: (clientId) => cleanupClientSubs(clientId),
|
|
533
533
|
});
|