@runeditrun/rer 0.2.0 → 0.2.1
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 +3 -2
- package/cli/README.md +4 -2
- package/cli/contract.mjs +2 -2
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +1 -1
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +3 -2
- package/cli/release.mjs +1 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +46 -31
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +25 -0
- package/global/README.md +3 -2
- package/global/main.mjs +2 -0
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +3 -0
- package/modules/deploy-kit/provider.mjs +45 -6
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +3 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
package/README.md
CHANGED
|
@@ -247,7 +247,7 @@ line that still names it. A later refresh is a version bump:
|
|
|
247
247
|
| `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
|
|
248
248
|
| `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
|
|
249
249
|
| `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
|
|
250
|
-
| `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export
|
|
250
|
+
| `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source`, `/signature` | release export, publication scan and shared signature verifier | `scripts/base-cli/modules/release/*.mjs` |
|
|
251
251
|
| `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
|
|
252
252
|
| `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
|
|
253
253
|
| `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
|
|
@@ -260,7 +260,8 @@ In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --hel
|
|
|
260
260
|
there lists its flags:
|
|
261
261
|
|
|
262
262
|
`install`, `setup`, `dev`, `preview`, `build`, `verify`, `reset --yes`, `deploy`,
|
|
263
|
-
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release
|
|
263
|
+
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`,
|
|
264
|
+
`release sign`, `release receipt` and `release keygen`.
|
|
264
265
|
|
|
265
266
|
Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
266
267
|
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
package/cli/README.md
CHANGED
|
@@ -131,8 +131,10 @@ Existing seeds receive this policy by refreshing `scripts/base-cli/` with
|
|
|
131
131
|
onboarding copy). For production, adopt the persistent root-head stylesheet,
|
|
132
132
|
primary preload and matched fallback described in `export/UI-CONVENTIONS.md`.
|
|
133
133
|
|
|
134
|
-
Both are managed
|
|
135
|
-
|
|
134
|
+
Both are managed servers. `--detach` returns after readiness, leaving the server
|
|
135
|
+
running; `--foreground` keeps the invocation active while serving. Preview
|
|
136
|
+
refreshes changed built assets and their ETags; source edits need a restart and
|
|
137
|
+
rebuild. A compatible repeated invocation verifies the registered application,
|
|
136
138
|
prints its safe ready/outbox URLs and exits without a new build or sign-in.
|
|
137
139
|
Explicit conflicting host, port or timeout, or the other mode, refuses with
|
|
138
140
|
exit 4 and the correct stop command. `--stop` is idempotent;
|
package/cli/contract.mjs
CHANGED
|
@@ -6,8 +6,8 @@ export const commonFlags = ['--help', '--cwd', '--non-interactive', '--json'];
|
|
|
6
6
|
const definitions = {
|
|
7
7
|
install: [[], 'Frozen dependency installation; no setup or global tool changes.'],
|
|
8
8
|
setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers', '--door'], 'Build, migrate, prepare private settings and first owner; stop the temporary app. --door sets a fresh checkout up at its front-door origin http://NAME.localhost:PORT (after rer door enable).'],
|
|
9
|
-
dev: [['--port', '--host', '--idle-timeout', '--stop', '--as', '--login', '--models', '--models-service', '--door', '--direct'], '
|
|
10
|
-
preview: [['--port', '--host', '--idle-timeout', '--stop'
|
|
9
|
+
dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Development with assets, HMR and the declared scheduled triggers run locally on their schedule. --detach returns once ready; --foreground keeps this command serving. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
|
|
10
|
+
preview: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground'], 'Production output; --detach returns once ready, --foreground keeps this command serving. Reuse intact build, refresh changed assets, preserve owner/state, idle timeout and explicit stop.'],
|
|
11
11
|
build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
|
|
12
12
|
verify: [[], 'Run the complete internal verification target.'],
|
|
13
13
|
reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
|
package/cli/deploy.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { deployDiagnostic } from '../modules/deploy-kit/diagnostics.mjs';
|
|
4
|
+
import { shared } from './shared.mjs';
|
|
3
5
|
import { contained } from './project.mjs';
|
|
4
6
|
import { missing, invalid, refuse } from './errors.mjs';
|
|
5
7
|
import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
|
|
@@ -30,8 +32,20 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
30
32
|
if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
|
|
31
33
|
const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
|
|
32
34
|
const argv = ['deploy', '--declaration', declarationPath, '--environment', environment];
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const {createRedaction,credentialValues} = await shared('redaction.mjs');
|
|
36
|
+
const redaction = createRedaction();
|
|
37
|
+
redaction.addSecrets([...credentialValues(process.env),process.env.CLOUDFLARE_ACCOUNT_ID,...(declaration.secrets ?? []).map(item=>process.env[item.environment]).filter(Boolean)]);
|
|
38
|
+
// Keep confirmed creation events across deploy and the independent health call.
|
|
39
|
+
const created = [];
|
|
40
|
+
try {
|
|
41
|
+
const result = await main(argv, process.env, { cwd: root, localOnlyKeys, notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
|
|
42
|
+
await main(['health', ...argv.slice(1)], process.env, { cwd: root, localOnlyKeys });
|
|
43
|
+
return { deployed: true, origin: result.origin };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const diagnostic = deployDiagnostic(error,redaction.redact);
|
|
46
|
+
const code = /^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redaction.redact(error.message) : 'deploy-failed';
|
|
47
|
+
const {commandFailure} = await shared('failures.mjs');
|
|
48
|
+
throw commandFailure(`Deploy failed at ${diagnostic?.step ?? 'prepare deploy'}: ${diagnostic?.code ?? code}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.\nFirst redacted output lines:\n${diagnostic?.output ?? '(no provider output)'}\nResources created this attempt: ${created.join(', ') || 'none recorded'}.`);
|
|
49
|
+
}
|
|
36
50
|
}, { declaration: declarationPath });
|
|
37
51
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { open, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { shared } from './shared.mjs';
|
|
8
|
+
import { ready } from './managed-server.mjs';
|
|
9
|
+
import { RerError } from './errors.mjs';
|
|
10
|
+
|
|
11
|
+
// The actual dispatcher owns the server, registry and shutdown. Only its
|
|
12
|
+
// lifetime changes: its parent follows private JSON events until readiness,
|
|
13
|
+
// then leaves that process serving. A failed/interrupted start stops its child.
|
|
14
|
+
export async function detachedServer(options, argv, out) {
|
|
15
|
+
const {privateDirectory} = await shared('private-files.mjs');
|
|
16
|
+
const directory = await privateDirectory(options.cwd,['.rer','local']);
|
|
17
|
+
const log = join(directory,`${options.verb}-launch-${randomUUID()}.log`), handle = await open(log,'wx',0o600);
|
|
18
|
+
const args = argv.filter(arg => !['--detach','--foreground','--json'].includes(arg));
|
|
19
|
+
let child, exit, spawnError, keep = false;
|
|
20
|
+
const stop = () => child?.kill('SIGTERM');
|
|
21
|
+
for (const signal of ['SIGINT','SIGTERM']) process.once(signal,stop);
|
|
22
|
+
try {
|
|
23
|
+
child = spawn(process.execPath,[fileURLToPath(new URL('../bin/rer.mjs',import.meta.url)),...args,'--foreground','--json'], {cwd:options.cwd, env:process.env, detached:true, stdio:['ignore',handle.fd,handle.fd]});
|
|
24
|
+
child.once('error',error => {spawnError=error;exit=1;});
|
|
25
|
+
child.once('exit',(code,signal) => {exit=code ?? (signal ? 128 : 1);});
|
|
26
|
+
await handle.close();
|
|
27
|
+
let offset=0, pending='', failure;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const bytes = await readFile(log), text = bytes.subarray(offset).toString('utf8'); offset=bytes.length;
|
|
30
|
+
pending += text;
|
|
31
|
+
const lines=pending.split('\n');pending=lines.pop();
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
let event; try { event=JSON.parse(line); } catch { continue; }
|
|
34
|
+
if (event.event==='error') failure=event;
|
|
35
|
+
if (event.event==='ready') {
|
|
36
|
+
keep=true;child.unref();
|
|
37
|
+
const dev = event.openUrl ? {openUrl:event.openUrl,identity:options.verb === 'dev' && !options.login,signedInAs:event.signedInAs,ownerLeft:options.verb === 'dev' && !options.login && event.signedInAs === null} : undefined;
|
|
38
|
+
ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (event.event==='waiting' || event.event==='warning') out.event(event.event,event);
|
|
42
|
+
}
|
|
43
|
+
if (exit!==undefined) throw new RerError(failure?.code ?? 'command_failed',failure?.message ?? `The ${options.verb} server stopped before readiness (${spawnError?.code ?? exit}); inspect ${log}.`,failure?.exitCode ?? (exit || 1));
|
|
44
|
+
await delay(200);
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
await handle.close().catch(() => {});
|
|
48
|
+
for (const signal of ['SIGINT','SIGTERM']) process.off(signal,stop);
|
|
49
|
+
if (!keep) stop();
|
|
50
|
+
}
|
|
51
|
+
}
|
package/cli/hosted-output.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
1
|
+
import { readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import { join, resolve, extname } from 'node:path';
|
|
@@ -114,7 +114,7 @@ export async function checkHostedOutput(root, options) {
|
|
|
114
114
|
} finally { await hosted.close(); }
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
if (process.argv[1] && import.meta.url ===
|
|
117
|
+
if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
|
|
118
118
|
try { console.log(JSON.stringify(await checkHostedOutput(resolve(process.argv[2] ?? '.'), { declaration: process.argv[3] }))); }
|
|
119
119
|
catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
120
120
|
}
|
package/cli/hydration/check.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// is replaced, an intro replays, a frame loses a section, recovery is noisy or
|
|
8
8
|
// uncounted, or a hydrated page's document lacks Cache-Control: no-transform.
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { realpath } from 'node:fs/promises';
|
|
11
11
|
import { RerError } from '../errors.mjs';
|
|
12
12
|
import { hostedServer, launchChromium, originServer, pool, runLoad } from './browser.mjs';
|
|
13
13
|
import { cloudflareRewrite, describeCase, planCases, selectPages } from './cases.mjs';
|
|
@@ -90,7 +90,7 @@ async function declaredPages(root) {
|
|
|
90
90
|
catch { return {}; }
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
if (process.argv[1] && import.meta.url ===
|
|
93
|
+
if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
|
|
94
94
|
const args = process.argv.slice(2), flag = name => { const at = args.indexOf(name); return at < 0 ? undefined : args.splice(at, 2)[1]; };
|
|
95
95
|
const origin = flag('--origin'), pages = flag('--pages')?.split(',').filter(Boolean), details = args.includes('--details');
|
|
96
96
|
const root = resolve(args.find(arg => !arg.startsWith('--')) ?? '.'), declared = await declaredPages(root);
|
package/cli/inspect.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export async function describe(project) {
|
|
|
31
31
|
};
|
|
32
32
|
const capabilities = project.adapter?.verbs ?? {};
|
|
33
33
|
return { contractVersion: 1, ...versioned, profile: 'first-party', defaults, pins: { node: pkg.engines?.node, pnpm: pkg.packageManager?.replace(/^pnpm@/, '') },
|
|
34
|
-
verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? '
|
|
34
|
+
verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.' }])),
|
|
35
35
|
aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
|
|
36
36
|
local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
|
|
37
37
|
// This pin serves a checkout through the machine's front door (rer door).
|
package/cli/main.mjs
CHANGED
|
@@ -27,6 +27,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
27
27
|
if (project.profile === 'independent' && !['describe', 'check', 'doctor'].includes(options.verb)) options = parse(argv, process.cwd(), { independent: true });
|
|
28
28
|
else if (options.forwarded.length) throw invalid('First-party commands accept only the documented flags, without forwarded arguments.');
|
|
29
29
|
out = output(options);
|
|
30
|
+
if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return (await import('./detached-server.mjs')).detachedServer(options, argv, out);
|
|
30
31
|
if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
|
|
31
32
|
if (options.verb === 'check') {
|
|
32
33
|
const result = await check(project, options.profile);
|
package/cli/managed-server.mjs
CHANGED
|
@@ -131,8 +131,14 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
131
131
|
const outbox=await outboxServed(selected.url,devKey,controller.signal);
|
|
132
132
|
if(options.verb==='dev'&&project.adapter.site){
|
|
133
133
|
const {devSite}=await import('./site.mjs');
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
try {
|
|
135
|
+
site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
|
|
136
|
+
await registry.update({siteUrl:site.url});
|
|
137
|
+
} catch(error) {
|
|
138
|
+
if(error.code!=='site_private_data')throw error;
|
|
139
|
+
const message=`Run site preview refused: ${error.message} The app is running; fix the site and run rer build --site before publishing it.`;
|
|
140
|
+
if(options.json)out.event('warning',{code:error.code,message});else out.progress(`Warning: ${message}\n`);
|
|
141
|
+
}
|
|
136
142
|
}
|
|
137
143
|
activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
|
|
138
144
|
if(crons.length){
|
|
@@ -147,8 +153,9 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
147
153
|
}catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
|
|
148
154
|
}
|
|
149
155
|
function reportScheduled(out,options,run){
|
|
150
|
-
const
|
|
151
|
-
|
|
156
|
+
const error=options.verb==='preview'&&run.status===404 ? 'The preview target does not expose /cdn-cgi/local/scheduled. Forward the adapter context scheduled flag to previewTarget, or set RER_RUNTIME_TRIGGERS=1 in a custom target using the shared production runtime. For another runtime, expose that internal trigger endpoint or declare scheduled:false.' : run.error;
|
|
157
|
+
const at=new Date().toISOString().replace(/\.\d+Z$/,'Z'),outcome=run.skipped?`skipped (${error})`:run.ok?`ok ${run.ms}ms`:`failed (${run.status?`HTTP ${run.status}`:error}) ${run.ms}ms`;
|
|
158
|
+
if(options.json)out.event('scheduled',{at,cron:run.cron,ok:run.ok,skipped:Boolean(run.skipped),status:run.status,ms:run.ms,...(error?{error}:{})});else out.human(`RER_SCHEDULED ${at} "${run.cron}" ${outcome}${error&&run.status?` — ${error}`:''}`);
|
|
152
159
|
}
|
|
153
160
|
// GET, not HEAD: a seed's outbox route need not answer HEAD. The body is never read.
|
|
154
161
|
async function outboxServed(url,devKey,signal){
|
|
@@ -169,7 +176,7 @@ async function emitReady(out,options,root,url,dev,outbox,siteUrl){
|
|
|
169
176
|
}
|
|
170
177
|
async function devReady(url,key,options,signal){
|
|
171
178
|
if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
|
|
172
|
-
if(options.login)return {openUrl:openUrl(url,key,{next:'/__dev/outbox'})};
|
|
179
|
+
if(options.login)return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
|
|
173
180
|
const as=options.as??'owner',persona=await resolvePersona(url,key,as,signal);
|
|
174
181
|
// The owner persona names the owner the session will actually have (the
|
|
175
182
|
// remaining owner once the setup owner has left), or none when none is left.
|
package/cli/parse.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
|
|
|
2
2
|
import { commonFlags, serverDefaults, verbs } from './contract.mjs';
|
|
3
3
|
import { invalid, refuse } from './errors.mjs';
|
|
4
4
|
import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
|
|
5
|
-
const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
|
|
5
|
+
const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
|
|
6
6
|
export const mailbox = value => typeof value === 'string' && value.length <= 254 && /^[a-zA-Z0-9.!+_%\-]+@[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+$/.test(value);
|
|
7
7
|
// A loopback host: the shared predicate (including `*.localhost` door names),
|
|
8
8
|
// plus `::1` as --host and a bracket-stripped hostname spell it.
|
|
@@ -45,7 +45,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
45
45
|
}
|
|
46
46
|
if (options.help) return options;
|
|
47
47
|
if (deferTail) { options.cwd=resolve(cwd,options.cwd);return options; }
|
|
48
|
-
if(options.
|
|
48
|
+
if (options.detach && options.foreground) throw invalid('Choose one of --detach and --foreground.');
|
|
49
|
+
if(options.stop && ['--port','--host','--idle-timeout','--detach','--foreground','--no-queue','--as','--login','--models','--models-service','--door','--direct'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
|
|
49
50
|
// --port and --host always mean direct mode; --door and --direct pick a mode.
|
|
50
51
|
if(options.door&&options.direct)throw invalid('Choose one of --door and --direct.');
|
|
51
52
|
if(options.door&&['--port','--host'].some(flag=>options.supplied.has(flag)))throw invalid('--door cannot be combined with --port or --host, which always serve directly.');
|
package/cli/release.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Preserve release's accepted parser, producer, receipt and validation exits.
|
|
2
2
|
export async function dispatchRelease(args) {
|
|
3
|
+
if (['sign', 'receipt', 'keygen'].includes(args[0])) return (await import('../modules/release/signing-cli.mjs')).dispatchSigning(args[0], args.slice(1));
|
|
3
4
|
try {
|
|
4
5
|
const options = {};
|
|
5
6
|
for (let index = 0; index < args.length; index++) {
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { walkSyntax, stringValue, memberName } from './site-syntax.mjs';
|
|
2
|
+
import { refuse } from './errors.mjs';
|
|
3
|
+
const finite = (values, key = value => value) => {
|
|
4
|
+
const unique = [...new Map(values.map(value => [key(value), value])).values()];
|
|
5
|
+
if (unique.length > 1024) throw refuse('The run site has too many possible values for a browser resource reference to check.');
|
|
6
|
+
return unique;
|
|
7
|
+
};
|
|
8
|
+
const propertyNames = (node, strings) => {
|
|
9
|
+
const key = node?.type === 'MemberExpression' ? node.property : node?.key;
|
|
10
|
+
if (!key) return [];
|
|
11
|
+
if (!node.computed) return [key.name ?? String(key.value)];
|
|
12
|
+
return key.type === 'Literal' && typeof key.value === 'number' ? [String(key.value)] : [...strings(key)];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Keep bindings lexical: a library's parameter or local variable must not take
|
|
16
|
+
// an unrelated same-named value from the island. Resolve finite constant values
|
|
17
|
+
// at a resource sink, rather than treating every URL-shaped string as a fetch.
|
|
18
|
+
export function localValues(tree) {
|
|
19
|
+
const scopes = new WeakMap(), writesByName = new Map(), computedWrites = [], assigned = new WeakMap(), root = {bindings:new Map(), parent:null, function:true};
|
|
20
|
+
const writeIndex = name => {
|
|
21
|
+
if (!writesByName.has(name)) writesByName.set(name, {writes:new Set(), users:new Set()});
|
|
22
|
+
return writesByName.get(name);
|
|
23
|
+
};
|
|
24
|
+
const bind = (scope, name, value) => {
|
|
25
|
+
if (!scope.bindings.has(name)) scope.bindings.set(name, []);
|
|
26
|
+
if (value) scope.bindings.get(name).push(value);
|
|
27
|
+
};
|
|
28
|
+
const lookup = (node, name) => {
|
|
29
|
+
let scope = scopes.get(node);
|
|
30
|
+
while (scope && !scope.bindings.has(name)) scope = scope.parent;
|
|
31
|
+
return scope?.bindings.get(name);
|
|
32
|
+
};
|
|
33
|
+
const bindPattern = (scope, pattern, value) => {
|
|
34
|
+
if (pattern.type === 'Identifier') { bind(scope, pattern.name, value); return; }
|
|
35
|
+
if (pattern.type === 'RestElement') { bindPattern(scope, pattern.argument); return; }
|
|
36
|
+
if (pattern.type === 'AssignmentPattern') { bindPattern(scope, pattern.left, value); bindPattern(scope, pattern.left, pattern.right); return; }
|
|
37
|
+
for (const [index, item] of (pattern.type === 'ObjectPattern' ? pattern.properties : pattern.elements ?? []).entries()) {
|
|
38
|
+
if (!item) continue;
|
|
39
|
+
const target = pattern.type === 'ObjectPattern' ? item.value ?? item.argument : item;
|
|
40
|
+
const key = pattern.type === 'ObjectPattern' ? item.key : {type:'Literal', value:index};
|
|
41
|
+
const member = value && key ? {type:'MemberExpression', object:value, property:key, computed:pattern.type !== 'ObjectPattern' || item.computed || key.type === 'Literal'} : null;
|
|
42
|
+
if (member) scopes.set(member, scope);
|
|
43
|
+
bindPattern(scope, target, member);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walkSyntax(tree, (node, path) => {
|
|
47
|
+
const parent = path.at(-1);
|
|
48
|
+
let scope = scopes.get(parent) ?? root;
|
|
49
|
+
if (['FunctionDeclaration','ClassDeclaration'].includes(node.type) && node.id) bind(scope, node.id.name, node);
|
|
50
|
+
if (node.type === 'ImportDeclaration') for (const specifier of node.specifiers) bind(scope, specifier.local.name);
|
|
51
|
+
if (/^(?:FunctionExpression|FunctionDeclaration|ArrowFunctionExpression)$/.test(node.type) || node.type === 'BlockStatement' || node.type === 'CatchClause') {
|
|
52
|
+
scope = {bindings:new Map(), parent:scope, function:node.type !== 'BlockStatement' && node.type !== 'CatchClause'};
|
|
53
|
+
if (node.type === 'FunctionExpression' && node.id) bind(scope, node.id.name, node);
|
|
54
|
+
for (const param of node.params ?? (node.param ? [node.param] : [])) bindPattern(scope, param);
|
|
55
|
+
}
|
|
56
|
+
scopes.set(node, scope);
|
|
57
|
+
if (node.type === 'VariableDeclarator') {
|
|
58
|
+
if (parent.kind === 'var') while (!scope.function && scope.parent) scope = scope.parent;
|
|
59
|
+
bindPattern(scope, node.id, node.init);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
walkSyntax(tree, node => {
|
|
63
|
+
if (node.type !== 'AssignmentExpression') return;
|
|
64
|
+
const operator = node.operator.slice(0, -1);
|
|
65
|
+
const value = node.operator === '=' ? node.right : {type:['&&','||','??'].includes(operator) ? 'LogicalExpression' : 'BinaryExpression', operator, left:node.left, right:node.right};
|
|
66
|
+
assigned.set(node, value);
|
|
67
|
+
if (node.left.type === 'MemberExpression') {
|
|
68
|
+
const name = memberName(node.left), write = {left:node.left, right:value};
|
|
69
|
+
if (name === undefined) computedWrites.push(write);
|
|
70
|
+
else writeIndex(name).writes.add(write);
|
|
71
|
+
}
|
|
72
|
+
if (node.left.type !== 'Identifier') return;
|
|
73
|
+
lookup(node, node.left.name)?.push(value);
|
|
74
|
+
});
|
|
75
|
+
// Resolve each expression once into a shared dataflow graph. Dependencies
|
|
76
|
+
// are re-evaluated only when they gain a value, so cyclic aliases converge
|
|
77
|
+
// instead of exploring every possible path through member assignments.
|
|
78
|
+
const expressionResults = new WeakMap(), expressionStates = new WeakMap(), stringStates = new WeakMap(), writeStates = new WeakMap(), pending = new Set();
|
|
79
|
+
const states = kind => kind === 'strings' ? stringStates : expressionStates;
|
|
80
|
+
let evaluating;
|
|
81
|
+
const state = (map, node, kind) => {
|
|
82
|
+
let result = map.get(node);
|
|
83
|
+
if (!result) {
|
|
84
|
+
result = {node, kind, values:new Set(), users:new Set()};
|
|
85
|
+
map.set(node, result); pending.add(result);
|
|
86
|
+
}
|
|
87
|
+
if (evaluating) result.users.add(evaluating);
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
const read = (node, kind = 'expressions') => node ? state(states(kind), node, kind).values : new Set();
|
|
91
|
+
const keys = node => propertyNames(node, key => read(key, 'strings'));
|
|
92
|
+
const memberWrites = name => {
|
|
93
|
+
const index = writeIndex(name);
|
|
94
|
+
if (evaluating) index.users.add(evaluating);
|
|
95
|
+
return index.writes;
|
|
96
|
+
};
|
|
97
|
+
// A computed write joins the same fixed point as its key's bindings. Index
|
|
98
|
+
// each discovered key once and wake only readers of that member name.
|
|
99
|
+
for (const write of computedWrites) state(writeStates, write, 'writes');
|
|
100
|
+
function indexWrite(write) {
|
|
101
|
+
const names = keys(write.left);
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
const index = writeIndex(name);
|
|
104
|
+
if (index.writes.has(write)) continue;
|
|
105
|
+
index.writes.add(write);
|
|
106
|
+
for (const user of index.users) pending.add(user);
|
|
107
|
+
}
|
|
108
|
+
return names;
|
|
109
|
+
}
|
|
110
|
+
const isClass = node => ['ClassDeclaration','ClassExpression'].includes(node.type);
|
|
111
|
+
function classProperties(constructor, wanted, isStatic, fields, seen = new Set()) {
|
|
112
|
+
const found = new Map();
|
|
113
|
+
if (!isClass(constructor) || seen.has(constructor)) return found;
|
|
114
|
+
seen = new Set(seen).add(constructor);
|
|
115
|
+
let unknownKey = false;
|
|
116
|
+
for (const property of constructor.body.body) {
|
|
117
|
+
if (Boolean(property.static) !== isStatic || (fields !== undefined && (property.type === 'PropertyDefinition') !== fields)) continue;
|
|
118
|
+
const names = keys(property);
|
|
119
|
+
if (property.computed && !names.length) unknownKey = true;
|
|
120
|
+
for (const name of wanted) if (names.includes(name)) {
|
|
121
|
+
// Static fields initialize after methods even when written before them.
|
|
122
|
+
const previous = found.get(name)?.[0];
|
|
123
|
+
if (property.type === 'PropertyDefinition' || previous?.type !== 'PropertyDefinition') found.set(name, [property]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const inherited = wanted.filter(name => !found.has(name));
|
|
127
|
+
// Wait for a computed override's key instead of prematurely retaining the
|
|
128
|
+
// inherited definition in the monotone graph. Unknown keys stay unknown.
|
|
129
|
+
if (inherited.length && !unknownKey) for (const parent of read(constructor.superClass)) {
|
|
130
|
+
for (const [name, properties] of classProperties(parent, inherited, isStatic, fields, seen)) found.set(name, [...(found.get(name) ?? []), ...properties]);
|
|
131
|
+
}
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
function classMembers(constructor, names, isStatic) {
|
|
135
|
+
if (isStatic) return [...classProperties(constructor, names, true).values()].flat();
|
|
136
|
+
// Instance fields shadow prototype methods, including a method declared by
|
|
137
|
+
// a subclass. Within each category the nearest declaration wins.
|
|
138
|
+
const fields = classProperties(constructor, names, false, true);
|
|
139
|
+
const methods = classProperties(constructor, names.filter(name => !fields.has(name)), false, false);
|
|
140
|
+
return [...fields.values(), ...methods.values()].flat();
|
|
141
|
+
}
|
|
142
|
+
function expressionValues(node) {
|
|
143
|
+
if (node.type === 'Identifier') {
|
|
144
|
+
const values = lookup(node, node.name);
|
|
145
|
+
return values?.length ? values.flatMap(value => [...read(value)]) : [node];
|
|
146
|
+
}
|
|
147
|
+
if (node.type === 'MemberExpression') {
|
|
148
|
+
const names = keys(node);
|
|
149
|
+
const objects = read(node.object), members = [node];
|
|
150
|
+
const properties = items => {
|
|
151
|
+
for (const property of items) if (keys(property).some(key => names.includes(key))) members.push(...read(property.value));
|
|
152
|
+
};
|
|
153
|
+
for (const object of objects) {
|
|
154
|
+
if (object.type === 'ArrayExpression') for (const key of names) members.push(...read(object.elements[Number(key)]));
|
|
155
|
+
if (object.type === 'ObjectExpression') properties(object.properties);
|
|
156
|
+
if (isClass(object)) properties(classMembers(object, names, true));
|
|
157
|
+
if (object.type === 'NewExpression') for (const constructor of read(object.callee)) {
|
|
158
|
+
if (isClass(constructor)) properties(classMembers(constructor, names, false));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const key of names) for (const write of memberWrites(key)) {
|
|
162
|
+
if (write.left !== node && [...read(write.left.object)].some(object => objects.has(object))) members.push(...read(write.right));
|
|
163
|
+
}
|
|
164
|
+
return members;
|
|
165
|
+
}
|
|
166
|
+
if (node.type === 'ConditionalExpression') return [...read(node.consequent), ...read(node.alternate)];
|
|
167
|
+
if (node.type === 'LogicalExpression') return [...read(node.left), ...read(node.right)];
|
|
168
|
+
if (node.type === 'SequenceExpression') return read(node.expressions.at(-1));
|
|
169
|
+
if (node.type === 'AssignmentExpression') return read(assigned.get(node));
|
|
170
|
+
return [node];
|
|
171
|
+
}
|
|
172
|
+
function stringValues(node, seen = new Set()) {
|
|
173
|
+
if (!node || seen.has(node)) return [];
|
|
174
|
+
seen = new Set(seen).add(node);
|
|
175
|
+
const value = stringValue(node);
|
|
176
|
+
if (value !== undefined) return [value];
|
|
177
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') return finite(stringValues(node.left, seen).flatMap(left => stringValues(node.right, seen).map(right => left + right)));
|
|
178
|
+
if (node.type === 'TemplateLiteral') {
|
|
179
|
+
let values = [node.quasis[0].value.cooked ?? ''];
|
|
180
|
+
for (let i = 0; i < node.expressions.length; i++) values = finite(values.flatMap(left => stringValues(node.expressions[i], seen).map(right => left + right + (node.quasis[i + 1].value.cooked ?? ''))));
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
183
|
+
if (node.type === 'CallExpression' && ['decodeURI','decodeURIComponent','atob'].includes(node.callee.name)) return stringValues(node.arguments[0], seen).flatMap(value => {
|
|
184
|
+
try {return [node.callee.name === 'atob' ? Buffer.from(value,'base64').toString('utf8') : decodeURIComponent(value)];} catch {return [];}
|
|
185
|
+
});
|
|
186
|
+
// Expand only the finite syntax graph. Recursive string construction has
|
|
187
|
+
// an unknown tail, not an ever-growing set of invented complete strings.
|
|
188
|
+
// Alias states already contain their transitive values; unresolved member
|
|
189
|
+
// identities contain no additional strings and must not be expanded again.
|
|
190
|
+
return finite([...read(node)].filter(value => value !== node && !['Identifier','MemberExpression'].includes(value.type)).flatMap(value => stringValues(value, seen)));
|
|
191
|
+
}
|
|
192
|
+
function resolve(node, kind) {
|
|
193
|
+
if (!node) return [];
|
|
194
|
+
const result = state(states(kind), node, kind);
|
|
195
|
+
for (const next of pending) {
|
|
196
|
+
pending.delete(next); evaluating = next;
|
|
197
|
+
const values = next.kind === 'writes' ? indexWrite(next.node) : next.kind === 'strings' ? stringValues(next.node) : expressionValues(next.node);
|
|
198
|
+
let changed = false;
|
|
199
|
+
for (const value of values) if (!next.values.has(value)) { next.values.add(value); changed = true; }
|
|
200
|
+
if (next.kind === 'strings') finite([...next.values]);
|
|
201
|
+
if (changed) for (const user of next.users) pending.add(user);
|
|
202
|
+
}
|
|
203
|
+
evaluating = undefined;
|
|
204
|
+
return [...result.values];
|
|
205
|
+
}
|
|
206
|
+
function expressions(node) {
|
|
207
|
+
if (!node) return [];
|
|
208
|
+
if (expressionResults.has(node)) return expressionResults.get(node);
|
|
209
|
+
const values = resolve(node, 'expressions').filter(value => {
|
|
210
|
+
if (value.type !== 'MemberExpression') return true;
|
|
211
|
+
// A member is its own unresolved identity until a value reaches it. Keep
|
|
212
|
+
// unresolved cycles, but remove that identity after resolving an alias.
|
|
213
|
+
return ![...expressionStates.get(value).values].some(other => other !== value && (other.type !== 'MemberExpression' || !expressionStates.get(other)?.values.has(value)));
|
|
214
|
+
});
|
|
215
|
+
// Indexing is complete and every dependency has settled before caching.
|
|
216
|
+
expressionResults.set(node, values);
|
|
217
|
+
return values;
|
|
218
|
+
}
|
|
219
|
+
const strings = node => resolve(node, 'strings');
|
|
220
|
+
const names = node => propertyNames(node, strings);
|
|
221
|
+
const origins = expressions;
|
|
222
|
+
const localClass = node => {
|
|
223
|
+
const values = expressions(node);
|
|
224
|
+
return values.length > 0 && values.every(value => {
|
|
225
|
+
const constructors = value.type === 'NewExpression' ? expressions(value.callee) : [value];
|
|
226
|
+
return constructors.length > 0 && constructors.every(isClass);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
// Recognize browser globals through aliases without mistaking library methods
|
|
230
|
+
// or shadowed parameters for those APIs.
|
|
231
|
+
function global(node, name, seen = new Set()) {
|
|
232
|
+
if (!node || seen.has(node)) return false;
|
|
233
|
+
seen = new Set(seen).add(node);
|
|
234
|
+
return origins(node).some(value => {
|
|
235
|
+
if (value.type === 'Identifier') return value.name === name && !lookup(value, value.name);
|
|
236
|
+
return value.type === 'MemberExpression' && names(value).includes(name) && ['window','globalThis','self'].some(root => global(value.object, root, seen));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// A runtime suffix does not erase a known origin or route at a resource sink.
|
|
240
|
+
// Retain only the prefix before the first unknown value; later fragments are
|
|
241
|
+
// not a trustworthy prefix. Complete values still use ordinary file checks.
|
|
242
|
+
const alternatives = values => finite(values, value => JSON.stringify(value));
|
|
243
|
+
const combine = (left, right) => alternatives(left.flatMap(start => start.complete ? right.map(end => ({value:start.value + end.value, complete:end.complete})) : [start]));
|
|
244
|
+
function resources(node, seen = new Set()) {
|
|
245
|
+
const unknown = [{value:'', complete:false}];
|
|
246
|
+
if (!node || seen.has(node)) return unknown;
|
|
247
|
+
seen = new Set(seen).add(node);
|
|
248
|
+
const value = stringValue(node);
|
|
249
|
+
if (value !== undefined) return [{value, complete:true}];
|
|
250
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') return combine(resources(node.left, seen), resources(node.right, seen));
|
|
251
|
+
if (node.type === 'TemplateLiteral') {
|
|
252
|
+
let values = [{value:node.quasis[0].value.cooked ?? '', complete:true}];
|
|
253
|
+
for (let i = 0; i < node.expressions.length; i++) values = combine(combine(values, resources(node.expressions[i], seen)), [{value:node.quasis[i + 1].value.cooked ?? '', complete:true}]);
|
|
254
|
+
return values;
|
|
255
|
+
}
|
|
256
|
+
if (node.type === 'NewExpression' && global(node.callee, 'Request')) return resources(node.arguments[0], seen);
|
|
257
|
+
if (node.type === 'NewExpression' && global(node.callee, 'URL')) {
|
|
258
|
+
const targets = resources(node.arguments[0], seen), bases = resources(node.arguments[1], seen);
|
|
259
|
+
const written = [...memberWrites('href')].filter(write => expressions(write.left.object).includes(node)).flatMap(write => resources(write.right, seen));
|
|
260
|
+
return alternatives([...written, ...targets.flatMap(target => {
|
|
261
|
+
// Absolute targets do not depend on the base. Incomplete targets retain
|
|
262
|
+
// their known prefix, including a foreign origin or an instance subtree.
|
|
263
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target.value)) return [target];
|
|
264
|
+
if (!node.arguments[1]) return unknown;
|
|
265
|
+
return bases.flatMap(base => {
|
|
266
|
+
if (!base.complete) {
|
|
267
|
+
if (target.value && /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(base.value)) return [base];
|
|
268
|
+
return target.value.startsWith('/') ? [target] : unknown;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
if (!target.value && !target.complete) return unknown;
|
|
272
|
+
return [{value:new URL(target.value, base.value).href, complete:target.complete}];
|
|
273
|
+
} catch { return unknown; }
|
|
274
|
+
});
|
|
275
|
+
})]);
|
|
276
|
+
}
|
|
277
|
+
if (node.type === 'MemberExpression' && names(node).some(name => ['href','url'].includes(name))) {
|
|
278
|
+
const objects = expressions(node.object), values = objects.flatMap(object => object.type === 'NewExpression' && (global(object.callee, 'URL') || global(object.callee, 'Request')) ? resources(object, seen) : []);
|
|
279
|
+
if (values.length) return alternatives(values);
|
|
280
|
+
}
|
|
281
|
+
if (node.type === 'CallExpression' && names(node.callee).some(name => ['toString','toJSON'].includes(name))) {
|
|
282
|
+
const values = expressions(node.callee.object).flatMap(object => object.type === 'NewExpression' && global(object.callee, 'URL') ? resources(object, seen) : []);
|
|
283
|
+
if (values.length) return alternatives(values);
|
|
284
|
+
}
|
|
285
|
+
const resolved = expressions(node).filter(value => value !== node);
|
|
286
|
+
if (resolved.length) return alternatives(resolved.flatMap(value => resources(value, seen)));
|
|
287
|
+
const complete = strings(node);
|
|
288
|
+
return complete.length ? complete.map(value => ({value, complete:true})) : unknown;
|
|
289
|
+
}
|
|
290
|
+
return {strings, names, resources, expressions, origins, global, localClass};
|
|
291
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { memberName } from './site-syntax.mjs';
|
|
2
|
+
|
|
3
|
+
// Follow actual browser object provenance. An object's field named `href`,
|
|
4
|
+
// or a URL parser's `href` setter, does not make that object a DOM element.
|
|
5
|
+
export function browserObjects({expressions, origins, strings, global}) {
|
|
6
|
+
const window = node => ['window','globalThis','self'].some(name => global(node, name));
|
|
7
|
+
const document = node => global(node, 'document');
|
|
8
|
+
const location = node => global(node, 'location') || origins(node).some(value => memberName(value) === 'location' && (window(value.object) || document(value.object)));
|
|
9
|
+
const constructed = (node, name) => expressions(node).some(value => value.type === 'NewExpression' && global(value.callee, name));
|
|
10
|
+
const queries = new Set(['querySelector','querySelectorAll','getElementById','getElementsByTagName','getElementsByClassName','getElementsByName','createElement','createElementNS']);
|
|
11
|
+
const children = new Set(['body','head','documentElement','activeElement','firstChild','lastChild','firstElementChild','lastElementChild','nextSibling','previousSibling','nextElementSibling','previousElementSibling','parentNode','parentElement','children','childNodes','forms','images','links','content','contentDocument']);
|
|
12
|
+
function dom(node, seen = new Set()) {
|
|
13
|
+
if (!node || seen.has(node)) return false;
|
|
14
|
+
seen = new Set(seen).add(node);
|
|
15
|
+
return expressions(node).some(value => {
|
|
16
|
+
if (document(value)) return true;
|
|
17
|
+
if (value.type === 'NewExpression') return ['Image','Audio','Option'].some(name => global(value.callee, name));
|
|
18
|
+
if (value.type === 'CallExpression') {
|
|
19
|
+
const method = memberName(value.callee), object = value.callee.object;
|
|
20
|
+
return (queries.has(method) || ['closest','cloneNode','item','namedItem'].includes(method)) && dom(object, seen);
|
|
21
|
+
}
|
|
22
|
+
if (value.type !== 'MemberExpression') return false;
|
|
23
|
+
return (children.has(memberName(value)) || value.computed) && dom(value.object, seen);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function anchor(node) {
|
|
27
|
+
return expressions(node).some(value => {
|
|
28
|
+
if (value.type !== 'CallExpression' || !dom(value.callee.object)) return false;
|
|
29
|
+
const method = memberName(value.callee);
|
|
30
|
+
if (['createElement','createElementNS','getElementsByTagName'].includes(method)) return strings(value.arguments[method === 'createElementNS' ? 1 : 0]).some(tag => /^(?:a|area)$/i.test(tag));
|
|
31
|
+
return ['querySelector','closest'].includes(method) && strings(value.arguments[0]).some(selector => /^(?:a|area)(?:[.#[:]|$)/i.test(selector));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const style = node => expressions(node).some(value => memberName(value) === 'style' && dom(value.object));
|
|
35
|
+
return {window, document, location, constructed, dom, anchor, style};
|
|
36
|
+
}
|