laravel-workers 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +295 -2
- package/bin/laravel-workers.mjs +141 -0
- package/build.mjs +2599 -0
- package/compiler/bin/controller.mjs +92 -0
- package/compiler/bin/transpile.mjs +65 -0
- package/compiler/runtime/rt-from-context.mjs +2399 -0
- package/compiler/src/classes.mjs +1232 -0
- package/compiler/src/controller.mjs +1029 -0
- package/compiler/src/dist-swap.mjs +76 -0
- package/compiler/src/mail-reach.mjs +104 -0
- package/compiler/src/policy.mjs +345 -0
- package/compiler/src/transpiler.mjs +3094 -0
- package/compiler/src/view-name.mjs +21 -0
- package/kernel/auth.mjs +1502 -0
- package/kernel/cache.mjs +405 -0
- package/kernel/collections.mjs +1615 -0
- package/kernel/cookies.mjs +474 -0
- package/kernel/csrf.mjs +698 -0
- package/kernel/db.mjs +703 -0
- package/kernel/digest.mjs +360 -0
- package/kernel/encrypter.mjs +106 -0
- package/kernel/form-request.mjs +106 -0
- package/kernel/hash.mjs +113 -0
- package/kernel/html-entities.mjs +268 -0
- package/kernel/http.mjs +1803 -0
- package/kernel/image.mjs +341 -0
- package/kernel/mail.mjs +269 -0
- package/kernel/mailable.mjs +172 -0
- package/kernel/model.mjs +1226 -0
- package/kernel/multipart.mjs +161 -0
- package/kernel/paginator.mjs +336 -0
- package/kernel/php-json.mjs +220 -0
- package/kernel/php-serialize.mjs +245 -0
- package/kernel/queue.mjs +96 -0
- package/kernel/ratelimit.mjs +855 -0
- package/kernel/redis.mjs +264 -0
- package/kernel/resources.mjs +857 -0
- package/kernel/response.mjs +17 -0
- package/kernel/router.mjs +276 -0
- package/kernel/scheduler.mjs +236 -0
- package/kernel/service-adapters.mjs +49 -0
- package/kernel/session.mjs +934 -0
- package/kernel/storage.mjs +265 -0
- package/kernel/str.mjs +445 -0
- package/kernel/tests/auth.test.mjs +1074 -0
- package/kernel/tests/budget.test.mjs +527 -0
- package/kernel/tests/cache.test.mjs +290 -0
- package/kernel/tests/collections.test.mjs +534 -0
- package/kernel/tests/cookies.test.mjs +302 -0
- package/kernel/tests/csrf.test.mjs +597 -0
- package/kernel/tests/db.test.mjs +1069 -0
- package/kernel/tests/encrypter.test.mjs +90 -0
- package/kernel/tests/fixtures/laravel-1337-cookies.json +120 -0
- package/kernel/tests/hash.test.mjs +149 -0
- package/kernel/tests/http.test.mjs +1182 -0
- package/kernel/tests/image.test.mjs +235 -0
- package/kernel/tests/login-flow.test.mjs +509 -0
- package/kernel/tests/model.test.mjs +324 -0
- package/kernel/tests/oracle-app.mjs +32 -0
- package/kernel/tests/paginator.test.mjs +132 -0
- package/kernel/tests/php-json.test.mjs +263 -0
- package/kernel/tests/php-serialize.test.mjs +210 -0
- package/kernel/tests/ports.test.mjs +47 -0
- package/kernel/tests/ratelimit.test.mjs +975 -0
- package/kernel/tests/readiness.test.mjs +155 -0
- package/kernel/tests/redis.test.mjs +175 -0
- package/kernel/tests/registry-json.test.mjs +28 -0
- package/kernel/tests/resources.test.mjs +275 -0
- package/kernel/tests/router.test.mjs +281 -0
- package/kernel/tests/routes-json.test.mjs +32 -0
- package/kernel/tests/services.test.mjs +474 -0
- package/kernel/tests/session.test.mjs +709 -0
- package/kernel/tests/sqlite-integration.test.mjs +390 -0
- package/kernel/tests/storage.test.mjs +133 -0
- package/kernel/tests/str.test.mjs +83 -0
- package/kernel/tests/upload.test.mjs +193 -0
- package/kernel/tests/validation-messages.test.mjs +117 -0
- package/kernel/tests/validation.test.mjs +340 -0
- package/kernel/tests/validator-hooks.test.mjs +343 -0
- package/kernel/tests/validator.test.mjs +805 -0
- package/kernel/tests/vendor-lite.test.mjs +39 -0
- package/kernel/validation-messages.data.mjs +168 -0
- package/kernel/validation-messages.mjs +249 -0
- package/kernel/validation.mjs +123 -0
- package/kernel/validator.mjs +1538 -0
- package/package.json +68 -5
- package/shadow/run.mjs +784 -0
- package/shadow/scenarios/fresh-app.mjs +60 -0
- package/shadow/scenarios/lw-constructs-app.mjs +669 -0
- package/src/ports/dbcheck.mjs +22 -0
- package/src/worker.mjs +200 -0
- package/tools/_root.php +135 -0
- package/tools/bench.mjs +129 -0
- package/tools/class-dump.php +87 -0
- package/tools/compile-view.php +263 -0
- package/tools/config-dump.php +192 -0
- package/tools/constants-dump.php +46 -0
- package/tools/construct-audit.mjs +326 -0
- package/tools/constructs.json +1510 -0
- package/tools/docs-check.mjs +149 -0
- package/tools/event-dump.php +320 -0
- package/tools/formrequest-dump.php +229 -0
- package/tools/gate.mjs +36 -0
- package/tools/http-oracle.php +200 -0
- package/tools/init.mjs +73 -0
- package/tools/integrations.json +69 -0
- package/tools/mailable-views.php +116 -0
- package/tools/make-cookie-fixture.mjs +87 -0
- package/tools/make-html-entities.mjs +106 -0
- package/tools/model-dump.php +198 -0
- package/tools/php-auth-oracle.php +701 -0
- package/tools/php-cache-oracle.php +166 -0
- package/tools/php-crypto-oracle.php +113 -0
- package/tools/php-csrf-oracle.php +271 -0
- package/tools/php-http-pipeline-oracle.php +358 -0
- package/tools/php-json-oracle.php +102 -0
- package/tools/php-login-flow-oracle.php +342 -0
- package/tools/php-provision.mjs +172 -0
- package/tools/php-ratelimit-oracle.php +508 -0
- package/tools/php-runtime.mjs +342 -0
- package/tools/php-serialize-oracle.php +114 -0
- package/tools/php-session-oracle.php +266 -0
- package/tools/php-shadow-oracle.php +588 -0
- package/tools/policy-dump.php +240 -0
- package/tools/ports.mjs +51 -0
- package/tools/prerender.php +68 -0
- package/tools/readiness.mjs +255 -0
- package/tools/registry-json.mjs +22 -0
- package/tools/routes-dump.php +27 -0
- package/tools/routes-json.mjs +48 -0
- package/tools/schedule-dump.php +82 -0
- package/tools/schema-dump.php +158 -0
- package/tools/validation-messages.php +54 -0
- package/tools/vendor-lite.mjs +299 -0
- package/tools/verify-s0.php +209 -0
package/build.mjs
ADDED
|
@@ -0,0 +1,2599 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// build.mjs — Build pipeline (Laravel → Workers artifact)
|
|
3
|
+
//
|
|
4
|
+
// "Booting" happens HERE (build time): the real Laravel app renders pages and
|
|
5
|
+
// exports its route table. The artifact shipped to Cloudflare is request-time
|
|
6
|
+
// static: prerendered HTML + small JS ports. Nothing boots at request time.
|
|
7
|
+
//
|
|
8
|
+
// Route tiers (see README):
|
|
9
|
+
// ports — live JS handlers (registry.json → "ports"), highest priority
|
|
10
|
+
// static — manual page map (registry.json → "static")
|
|
11
|
+
// snapshot — automatic: clean GET routes rendered at build time land on the edge
|
|
12
|
+
// ignore — consciously skipped (registry.json → "ignore")
|
|
13
|
+
//
|
|
14
|
+
// Usage: node build.mjs [--strict] [--scaffold]
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { buildRoutesJson } from './tools/routes-json.mjs';
|
|
18
|
+
import { mergePorts, serializeRegistry } from './tools/registry-json.mjs';
|
|
19
|
+
import { STUB_MARKER, buildStubSource, findStubPorts } from './tools/ports.mjs';
|
|
20
|
+
import { BLOB_DDL, BLOB_TABLE } from './kernel/storage.mjs';
|
|
21
|
+
import { BLOCK, DECLARED, renderReadiness, sweepAppSources } from './tools/readiness.mjs';
|
|
22
|
+
import { compilePolicies } from './compiler/src/policy.mjs';
|
|
23
|
+
import { compileClasses, compileOneMethod } from './compiler/src/classes.mjs';
|
|
24
|
+
import { reachableClasses, reachedMailables } from './compiler/src/mail-reach.mjs';
|
|
25
|
+
import { viewFileName } from './compiler/src/view-name.mjs';
|
|
26
|
+
import { ensureVendorLite } from './tools/vendor-lite.mjs';
|
|
27
|
+
import { ORACLE_PHP, resolvePhp } from './tools/php-runtime.mjs';
|
|
28
|
+
import { provisionPhp } from './tools/php-provision.mjs';
|
|
29
|
+
import { commitDist, discardStaging } from './compiler/src/dist-swap.mjs';
|
|
30
|
+
import {
|
|
31
|
+
cpSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
lstatSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
} from 'node:fs';
|
|
41
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
const pkgDir = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
let appDir;
|
|
46
|
+
if (existsSync(join(process.cwd(), 'artisan')) && existsSync(join(process.cwd(), 'vendor'))) {
|
|
47
|
+
appDir = process.cwd();
|
|
48
|
+
} else if (existsSync(join(dirname(pkgDir), 'artisan'))) {
|
|
49
|
+
appDir = dirname(pkgDir);
|
|
50
|
+
} else {
|
|
51
|
+
appDir = process.cwd();
|
|
52
|
+
}
|
|
53
|
+
const isNpmMode = appDir !== pkgDir && existsSync(join(appDir, 'artisan'));
|
|
54
|
+
// The build writes into a STAGING directory and swaps it into place only after
|
|
55
|
+
// every artifact landed (see the end of this file). A build that fails halfway
|
|
56
|
+
// used to leave a half-written `dist/` behind - an operator (or a watcher)
|
|
57
|
+
// could then deploy an artifact the build never finished, and the bundler's
|
|
58
|
+
// error named a missing file instead of the skipped route that caused it.
|
|
59
|
+
const distFinal = isNpmMode ? join(appDir, 'dist') : join(pkgDir, 'dist');
|
|
60
|
+
const dist = distFinal + '.building';
|
|
61
|
+
const cacheDir = isNpmMode ? join(appDir, '.cache', 'laravel-workers') : join(pkgDir, '.cache');
|
|
62
|
+
const strict = process.argv.includes('--strict');
|
|
63
|
+
const scaffold = process.argv.includes('--scaffold');
|
|
64
|
+
// The interpreter that boots the app. It is RESOLVED below, once composer.json
|
|
65
|
+
// has been read: the app's own `require.php` decides which installed PHP can
|
|
66
|
+
// parse it, and hard-coding `php` from PATH fails for every user whose default
|
|
67
|
+
// differs from the app's. `PHP` stays a module-level binding so every tool call
|
|
68
|
+
// below uses the same binary.
|
|
69
|
+
let PHP = 'php';
|
|
70
|
+
// The PHP preflight lives below, after `fail` is initialised (calling a
|
|
71
|
+
// `const` before its declaration throws ReferenceError, and a bare catch
|
|
72
|
+
// would swallow it — the preflight would be dead code on exactly the path it
|
|
73
|
+
// exists to handle).
|
|
74
|
+
|
|
75
|
+
// Where the app's PHP dependencies live. An app that runs locally has them in
|
|
76
|
+
// <app>/vendor; a fresh clone has none, and `vendor:ensure` (tools/vendor-lite.mjs)
|
|
77
|
+
// keeps a content-addressed copy instead. Either way the helpers and artisan
|
|
78
|
+
// read the same override, so nothing below has to know which it is.
|
|
79
|
+
let vendorDir = process.env.LW_VENDOR_DIR ? resolve(process.env.LW_VENDOR_DIR) : null;
|
|
80
|
+
if (vendorDir) {
|
|
81
|
+
const ok = existsSync(join(vendorDir, 'autoload.php')) || existsSync(join(vendorDir, 'vendor/autoload.php'));
|
|
82
|
+
if (!ok) fail(`LW_VENDOR_DIR is set to "${process.env.LW_VENDOR_DIR}" but no autoload.php was found there (looked for <dir>/autoload.php and <dir>/vendor/autoload.php)`);
|
|
83
|
+
}
|
|
84
|
+
// Child PHP processes inherit this: artisan needs it, and the tools/*.php helpers
|
|
85
|
+
// read it through tools/_root.php.
|
|
86
|
+
const childEnv = () => ({ ...process.env, LW_APP_ROOT: appDir, ...(vendorDir ? { LW_VENDOR_DIR: vendorDir } : {}) });
|
|
87
|
+
|
|
88
|
+
// One place the operator looks to answer "can my app run at the edge?"
|
|
89
|
+
const findings = [];
|
|
90
|
+
const note = (f) => findings.push(f);
|
|
91
|
+
const relPath = (p) => relative(appDir, p).split('\\').join('/');
|
|
92
|
+
|
|
93
|
+
const fail = (msg) => {
|
|
94
|
+
console.error('BUILD FAILED: ' + msg);
|
|
95
|
+
// The staging directory is thrown away: `dist/` keeps the last build that
|
|
96
|
+
// COMPLETED (a failed build must never leave a half-written dist behind).
|
|
97
|
+
discardStaging(dist);
|
|
98
|
+
// A failed build must never print a green verdict. Failures raised before
|
|
99
|
+
// their own check runs (missing frontend manifest, PHP preflight) would
|
|
100
|
+
// otherwise leave `findings` empty and the banner would read RUNS ON THE
|
|
101
|
+
// EDGE directly under BUILD FAILED. Record the failure as the blocking
|
|
102
|
+
// finding so the verdict is DOES NOT RUN with the reason attached.
|
|
103
|
+
if (!findings.some((f) => f && f.severity === BLOCK)) {
|
|
104
|
+
note({
|
|
105
|
+
severity: BLOCK,
|
|
106
|
+
area: 'config',
|
|
107
|
+
cause: msg.split('\n')[0],
|
|
108
|
+
fix: 'fix the error above, then re-run the build',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
console.error(renderReadiness(findings, { dist: relPath(distFinal) }));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Resolve the interpreter that can boot THIS app.
|
|
116
|
+
//
|
|
117
|
+
// The app's `require.php` is the constraint, not the machine's default: an app
|
|
118
|
+
// declaring `^8.4` cannot be parsed by 8.3 at all, and an older app may not run
|
|
119
|
+
// on 8.4. `resolvePhp` honours PHP_BIN, then `php` from PATH, then any versioned
|
|
120
|
+
// interpreter installed on the machine (Laragon, XAMPP, C:\php, Herd,
|
|
121
|
+
// Homebrew, asdf, phpenv, mise), highest satisfying version first. When nothing
|
|
122
|
+
// satisfies it, the error names every interpreter found and the ways out.
|
|
123
|
+
let phpRequirement = null;
|
|
124
|
+
if (isNpmMode) {
|
|
125
|
+
try {
|
|
126
|
+
phpRequirement = JSON.parse(readFileSync(join(appDir, 'composer.json'), 'utf8')).require?.php || null;
|
|
127
|
+
} catch {
|
|
128
|
+
phpRequirement = null; // no/unreadable composer.json: no constraint to enforce
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let resolved = null;
|
|
132
|
+
try {
|
|
133
|
+
resolved = resolvePhp({ requirement: phpRequirement });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// Nothing installed can boot the app. When the app declares a requirement and
|
|
136
|
+
// provisioning is allowed, fetch a portable interpreter into the cache rather
|
|
137
|
+
// than refusing to build: "this machine has no PHP 8.4" is not a reason an
|
|
138
|
+
// 8.4 app cannot be built. LW_NO_PHP_PROVISION=1 opts out (offline CI).
|
|
139
|
+
if (phpRequirement && !process.env.LW_NO_PHP_PROVISION) {
|
|
140
|
+
console.log(`• php: no installed interpreter satisfies ${phpRequirement} — provisioning a portable build`);
|
|
141
|
+
try {
|
|
142
|
+
const p = await provisionPhp(phpRequirement);
|
|
143
|
+
resolved = { bin: p.bin, version: p.version, source: p.source };
|
|
144
|
+
console.log(` -> php ${p.version} ${p.cached ? 'reused from cache' : 'downloaded and verified (sha256)'} at ${p.bin}`);
|
|
145
|
+
} catch (e2) {
|
|
146
|
+
fail(e.message + '\n automatic provisioning also failed: ' + e2.message);
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
fail(e.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
PHP = resolved.bin;
|
|
153
|
+
// A build with a different interpreter is valid — it boots the app — but the
|
|
154
|
+
// parity suite is pinned to ORACLE_PHP, so say which one was used. Silence
|
|
155
|
+
// here would let a version-specific divergence look like a compiler bug.
|
|
156
|
+
if (resolved.source !== 'PATH' || resolved.version !== ORACLE_PHP) {
|
|
157
|
+
console.log(
|
|
158
|
+
`• php: ${resolved.version} (${resolved.bin})` +
|
|
159
|
+
(phpRequirement ? ` — app requires ${phpRequirement}` : '') +
|
|
160
|
+
(resolved.version === ORACLE_PHP ? '' : `; parity suite is pinned to ${ORACLE_PHP}`)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const lwConfig = process.env.LW_CONFIG ? resolve(appDir, process.env.LW_CONFIG) : null;
|
|
164
|
+
if (lwConfig && !existsSync(lwConfig))
|
|
165
|
+
fail(`LW_CONFIG is set to "${process.env.LW_CONFIG}" but that file was not found (resolved: ${lwConfig})`);
|
|
166
|
+
const registryCandidates = [
|
|
167
|
+
...(lwConfig ? [lwConfig] : []),
|
|
168
|
+
join(appDir, 'registry.json'),
|
|
169
|
+
join(appDir, 'laravel-workers.json'),
|
|
170
|
+
join(appDir, 'edge/registry.json'),
|
|
171
|
+
join(pkgDir, 'registry.json'),
|
|
172
|
+
];
|
|
173
|
+
let registryPath = null;
|
|
174
|
+
for (const p of registryCandidates) if (existsSync(p)) { registryPath = p; break; }
|
|
175
|
+
if (lwConfig && registryPath === lwConfig) console.log(`• registry: LW_CONFIG -> ${registryPath}`);
|
|
176
|
+
let registry = {};
|
|
177
|
+
if (registryPath) {
|
|
178
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
179
|
+
} else {
|
|
180
|
+
registryPath = join(appDir, 'registry.json');
|
|
181
|
+
console.log('• registry: none found — using defaults (autoSnapshot on, no ports/ignore); add registry.json or set LW_CONFIG to declare routes');
|
|
182
|
+
}
|
|
183
|
+
const lwPortsDir = process.env.LW_PORTS_DIR ? resolve(appDir, process.env.LW_PORTS_DIR) : null;
|
|
184
|
+
if (lwPortsDir && !existsSync(lwPortsDir))
|
|
185
|
+
fail(`LW_PORTS_DIR is set to "${process.env.LW_PORTS_DIR}" but that directory was not found (resolved: ${lwPortsDir})`);
|
|
186
|
+
const autoSnapshot = registry.autoSnapshot !== false;
|
|
187
|
+
const staticMap = Object.entries(registry.static || {});
|
|
188
|
+
const portEntries = Object.entries(registry.ports || {});
|
|
189
|
+
const ignoreMap = Object.entries(registry.ignore || {});
|
|
190
|
+
const viewMap = Object.entries(registry.views || {});
|
|
191
|
+
if (!vendorDir && !existsSync(join(appDir, 'vendor/autoload.php'))) {
|
|
192
|
+
// No vendor/ and no override: provision a composer-free vendor-lite cache
|
|
193
|
+
// (fetch dist zips + write our own autoloader - no composer install).
|
|
194
|
+
try {
|
|
195
|
+
const v = await ensureVendorLite(appDir, { silent: true });
|
|
196
|
+
vendorDir = v.dir;
|
|
197
|
+
console.log(` -> vendor: provisioned via composer-free vendor-lite at ${v.dir}`);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
note({
|
|
200
|
+
route: null,
|
|
201
|
+
area: 'config',
|
|
202
|
+
severity: BLOCK,
|
|
203
|
+
cause: 'the app has no vendor/ (and LW_VENDOR_DIR is not set)',
|
|
204
|
+
fix: 'the build boots your app to read its routes, config and schema, so it needs the PHP dependencies',
|
|
205
|
+
paths: [
|
|
206
|
+
{ tier: 'free', what: 'the laravel-workers vendor-lite installer fetches the needed packages directly (no composer install)' },
|
|
207
|
+
{ tier: 'free', what: 'or reuse a cached tree: npx laravel-workers vendor:ensure && LW_VENDOR_DIR=<printed dir> npx laravel-workers build --strict' },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
fail(`vendor provisioning failed: ${e.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// A Vite app writes public/build/manifest.json. A Mix/webpack app writes
|
|
214
|
+
// public/mix-manifest.json. A standalone esbuild (BookStack: public/dist) writes
|
|
215
|
+
// no manifest at all — the build can still proceed when the pages are declared
|
|
216
|
+
// in registry.json `static`. Failing on "public/build" alone made whole apps
|
|
217
|
+
// untestable before the first real decision. The order is Vite-first: the path
|
|
218
|
+
// the build WOULD write if its own `npm run build` ran.
|
|
219
|
+
{
|
|
220
|
+
const manifestCandidates = [
|
|
221
|
+
join(appDir, 'public/build/manifest.json'),
|
|
222
|
+
join(appDir, 'public/dist/manifest.json'),
|
|
223
|
+
join(appDir, 'public/mix-manifest.json'),
|
|
224
|
+
];
|
|
225
|
+
const manifestPath = manifestCandidates.find((p) => existsSync(p));
|
|
226
|
+
if (!manifestPath) {
|
|
227
|
+
if (Object.keys(registry.static || {}).length) {
|
|
228
|
+
console.log(
|
|
229
|
+
'\u2022 manifest: none of the usual frontend manifests found (' +
|
|
230
|
+
manifestCandidates.map((p) => relPath(p)).join(', ') +
|
|
231
|
+
') — but registry.json has "static", so pages declared there count as handled'
|
|
232
|
+
);
|
|
233
|
+
} else {
|
|
234
|
+
fail(
|
|
235
|
+
'no frontend manifest found — looked for ' +
|
|
236
|
+
manifestCandidates.map((p) => relPath(p)).join(', ') +
|
|
237
|
+
' — run the app\'s frontend build (npm run build) first, or declare pages in registry.json "static"'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
console.log(`\u2022 manifest: ${relPath(manifestPath)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- 1. export routes from the Laravel app ----------
|
|
246
|
+
console.log('• exporting routes via `php artisan route:list --json` (booted through the resolved autoloader)');
|
|
247
|
+
let listed;
|
|
248
|
+
try {
|
|
249
|
+
const rawRoutes = execFileSync(PHP, [join(pkgDir, 'tools', 'routes-dump.php')], { cwd: appDir, env: childEnv() }).toString('utf8');
|
|
250
|
+
try {
|
|
251
|
+
listed = JSON.parse(rawRoutes);
|
|
252
|
+
} catch {
|
|
253
|
+
// A Laravel boot failure (missing APP_KEY, a PHP-version parse error, an
|
|
254
|
+
// invalid config file) is rendered by the console kernel into STDOUT
|
|
255
|
+
// while the process still exits 0 — the real cause is in that text, not
|
|
256
|
+
// on stderr.
|
|
257
|
+
const tail = rawRoutes.trim() ? rawRoutes.trim().split('\n').slice(-15).map((l) => ' ' + l).join('\n') : ' (empty)';
|
|
258
|
+
fail(
|
|
259
|
+
'route export failed — the app did not return the JSON route table, so it failed to boot\n' +
|
|
260
|
+
' PHP output:\n' + tail +
|
|
261
|
+
'\n the build cannot continue without the route table — fix the error above, then re-run the build'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
// PHP compile/boot errors print to STDOUT even when the process exits
|
|
266
|
+
// non-zero, so both streams matter here. Reporting only stderr shipped a
|
|
267
|
+
// generic "missing env vars" hint for what was really a PHP parse error.
|
|
268
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : '';
|
|
269
|
+
const stdout = e.stdout ? e.stdout.toString().trim() : '';
|
|
270
|
+
const outTail = stdout ? stdout.split('\n').slice(-15).join('\n') : '';
|
|
271
|
+
const errTail = stderr ? stderr.split('\n').slice(-15).join('\n') : '';
|
|
272
|
+
const combined = [errTail, outTail].filter(Boolean).join('\n');
|
|
273
|
+
const errDetail = combined ? '\n PHP output:\n ' + combined.replace(/\n/g, '\n ') : '';
|
|
274
|
+
note({
|
|
275
|
+
severity: BLOCK,
|
|
276
|
+
area: 'route',
|
|
277
|
+
cause: 'the app failed to boot during route export' + (combined ? ': ' + combined.split('\n').pop().trim() : ''),
|
|
278
|
+
fix: 'check that the app has a .env with APP_KEY, DB connection and all required vars; then re-run',
|
|
279
|
+
});
|
|
280
|
+
fail(
|
|
281
|
+
'route export crashed' + errDetail +
|
|
282
|
+
'\n the build cannot continue without the route table — this usually means\n' +
|
|
283
|
+
' the app failed to boot (missing env vars, DB connection, etc.)\n' +
|
|
284
|
+
' -> fix the root cause in the app, then re-run the build'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Laravel only reports `path` for closure routes — controller-action routes
|
|
288
|
+
// (Route::get('/x', [XController::class, 'm']), Route::resource, invokable
|
|
289
|
+
// controllers) come back with path === null even though they live in routes/.
|
|
290
|
+
const isAppRoute = (r) => {
|
|
291
|
+
const p = String(r.path || '');
|
|
292
|
+
if (p) return p.startsWith('routes/');
|
|
293
|
+
const action = String(r.action || '');
|
|
294
|
+
return action !== '' && action !== 'Closure';
|
|
295
|
+
};
|
|
296
|
+
const appRoutes = listed.filter(isAppRoute);
|
|
297
|
+
const normalize = (u) => (u === '/' || u === '' ? '/' : '/' + String(u).replace(/^\/+|\/+$/g, ''));
|
|
298
|
+
const routes = [...new Set(appRoutes.map((r) => normalize(r.uri)))].sort();
|
|
299
|
+
const methodsFor = new Map();
|
|
300
|
+
const metaFor = new Map(); // 'METHOD /uri' -> { name, domain }
|
|
301
|
+
for (const r of appRoutes) {
|
|
302
|
+
const uri = normalize(r.uri);
|
|
303
|
+
const set = methodsFor.get(uri) || new Set();
|
|
304
|
+
const methods = String(r.method || 'GET').split('|');
|
|
305
|
+
for (const m of methods) if (m) set.add(m);
|
|
306
|
+
methodsFor.set(uri, set);
|
|
307
|
+
for (const m of methods) {
|
|
308
|
+
if (!m) continue;
|
|
309
|
+
const key = m.toUpperCase() + ' ' + uri;
|
|
310
|
+
if (!metaFor.has(key)) metaFor.set(key, { name: r.name ?? null, domain: r.domain ?? null, action: r.action ?? null, middleware: Array.isArray(r.middleware) ? r.middleware : null });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const otherMethods = (route) =>
|
|
314
|
+
[...(methodsFor.get(route) || new Set())].filter((m) => m !== 'GET' && m !== 'HEAD');
|
|
315
|
+
// first non-null name/domain/middleware across the route's methods (they share a
|
|
316
|
+
// uri in the table; middleware is preferred from GET like middlewareForRoute).
|
|
317
|
+
// The emitted `middleware` is what kernel/http.mjs uses to decide whether the
|
|
318
|
+
// `web` group (EncryptCookies → StartSession → CSRF) and route middleware
|
|
319
|
+
// (auth/guest/throttle) run — omitting it silently downgraded every compiled
|
|
320
|
+
// route to the bare, session-less path.
|
|
321
|
+
const metaForRoute = (route) => {
|
|
322
|
+
let name = null;
|
|
323
|
+
let domain = null;
|
|
324
|
+
let middleware = null;
|
|
325
|
+
for (const m of methodsFor.get(route) || []) {
|
|
326
|
+
const meta = metaFor.get(m + ' ' + route);
|
|
327
|
+
if (!meta) continue;
|
|
328
|
+
if (name == null) name = meta.name;
|
|
329
|
+
if (domain == null) domain = meta.domain;
|
|
330
|
+
if (middleware == null && meta.middleware) middleware = meta.middleware;
|
|
331
|
+
}
|
|
332
|
+
return { name, domain, middleware };
|
|
333
|
+
};
|
|
334
|
+
// route:list action for a route (prefer GET — the only method controller
|
|
335
|
+
// transpilation supports today).
|
|
336
|
+
const actionForRoute = (route) => {
|
|
337
|
+
const getMeta = metaFor.get('GET ' + route);
|
|
338
|
+
if (getMeta && getMeta.action) return getMeta.action;
|
|
339
|
+
for (const m of methodsFor.get(route) || []) {
|
|
340
|
+
const meta = metaFor.get(m + ' ' + route);
|
|
341
|
+
if (meta && meta.action) return meta.action;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
345
|
+
// route:list middleware for a route (prefer GET — see above).
|
|
346
|
+
const middlewareForRoute = (route) => {
|
|
347
|
+
const getMeta = metaFor.get('GET ' + route);
|
|
348
|
+
if (getMeta && getMeta.middleware) return getMeta.middleware;
|
|
349
|
+
for (const m of methodsFor.get(route) || []) {
|
|
350
|
+
const meta = metaFor.get(m + ' ' + route);
|
|
351
|
+
if (meta && meta.middleware) return meta.middleware;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
};
|
|
355
|
+
// The verb a uri's "main" handler answers: GET when present, else the first
|
|
356
|
+
// non-HEAD verb (POST for form-only routes). Extra verbs (e.g. Breeze's
|
|
357
|
+
// GET+POST /login) are compiled as separate per-verb handlers below.
|
|
358
|
+
const mainVerbFor = (route) => {
|
|
359
|
+
const set = methodsFor.get(route) || new Set();
|
|
360
|
+
if (set.has('GET')) return 'GET';
|
|
361
|
+
const others = [...set].filter((m) => m !== 'HEAD');
|
|
362
|
+
return others.length ? others[0] : null;
|
|
363
|
+
};
|
|
364
|
+
const extraVerbsFor = (route) =>
|
|
365
|
+
[...(methodsFor.get(route) || new Set())].filter((m) => m !== 'HEAD' && m !== mainVerbFor(route));
|
|
366
|
+
const metaForVerb = (route, verb) => metaFor.get(String(verb).toUpperCase() + ' ' + route) || null;
|
|
367
|
+
// Middleware kernel/http.mjs actually honours. auth/guest run on top of the web
|
|
368
|
+
// group; numeric `throttle:n[,m]` runs against the D1-backed `database` cache.
|
|
369
|
+
const HONOURED_MIDDLEWARE = new Set(['web', 'api', 'substitute_bindings', 'bindings', 'auth', 'auth.basic', 'guest']);
|
|
370
|
+
const isHonouredMiddleware = (m) => {
|
|
371
|
+
const str = String(m);
|
|
372
|
+
const name = str.split(':')[0];
|
|
373
|
+
if (HONOURED_MIDDLEWARE.has(name)) return true;
|
|
374
|
+
if (name !== 'throttle') return false;
|
|
375
|
+
const spec = str.slice('throttle'.length).replace(/^:/, '');
|
|
376
|
+
return spec === '' || /^\d+(,\d+)?$/.test(spec); // numeric spec only
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const MIDDLEWARE_FIX = (m, route) => {
|
|
380
|
+
const name = String(m).split(':')[0];
|
|
381
|
+
const ignore = '{\"ignore\": {\"' + route + '\": \"<why the edge does not serve it>\"}}';
|
|
382
|
+
switch (name) {
|
|
383
|
+
case 'verified': return 'email verification needs a signed URL the edge cannot mint the same way -- add a port, or ' + ignore;
|
|
384
|
+
case 'signed': return 'signed URLs are verified against app.key (a port can do it with WebCrypto) -- add a port, or ' + ignore;
|
|
385
|
+
case 'password.confirm': return 'the password-confirm window is session state the compiled rung does not port -- add a port, or ' + ignore;
|
|
386
|
+
case 'can': return 'a route-level `can:` check needs the policy at the route stage -- call `$this->authorize()` inside the controller instead (that compiles), or add a port';
|
|
387
|
+
case 'throttle': return 'a NAMED rate limiter lives in AppServiceProvider and the build cannot read it -- use a numeric throttle (e.g. throttle:60,1) or ' + ignore;
|
|
388
|
+
case 'cors': return 'CORS headers are added by a port or by the Cloudflare dashboard (Transform Rules) -- the compiled rung does not add them';
|
|
389
|
+
default: return 'add a port for this route (build --scaffold), or ' + ignore;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
console.log(` app routes (${routes.length}): ${routes.join(' ')}`);
|
|
393
|
+
const skippedRoutes = [
|
|
394
|
+
...new Set(listed.filter((r) => !isAppRoute(r)).map((r) => normalize(r.uri))),
|
|
395
|
+
];
|
|
396
|
+
if (skippedRoutes.length) {
|
|
397
|
+
const shown = skippedRoutes.slice(0, 5).join(', ');
|
|
398
|
+
console.log(
|
|
399
|
+
` skipped ${skippedRoutes.length} route(s) defined outside routes/ (not part of the edge build): ${shown}${
|
|
400
|
+
skippedRoutes.length > 5 ? ', …' : ''
|
|
401
|
+
}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------- 2. prerender helper (boots the real app, build time only) ----------
|
|
406
|
+
const originRe = /(?:https?:)?\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?\//g;
|
|
407
|
+
|
|
408
|
+
// The framework can write an error page to stdout around our payload (console
|
|
409
|
+
// exception renderers bypass PHP output buffering), so prerender.php terminates
|
|
410
|
+
// its stdout with a marker and the JSON that follows it.
|
|
411
|
+
const PRERENDER_MARKER = '---LW-PRERENDER-JSON---';
|
|
412
|
+
|
|
413
|
+
const isRuntimeBindingError = (reason) => /Database connection \[[^\]]+\] not configured|D1 (?:database configuration|Worker driver) requires (?:a|an) "/.test(String(reason));
|
|
414
|
+
const prerender = (route) => {
|
|
415
|
+
let out;
|
|
416
|
+
try {
|
|
417
|
+
out = execFileSync(PHP, ['-d', 'display_errors=stderr', join(pkgDir, 'tools/prerender.php'), route], {
|
|
418
|
+
cwd: appDir,
|
|
419
|
+
env: childEnv(),
|
|
420
|
+
}).toString('utf8');
|
|
421
|
+
} catch (e) {
|
|
422
|
+
const stderr = String(e.stderr || '').trim();
|
|
423
|
+
const stdout = String(e.stdout || '').trim();
|
|
424
|
+
const detail = (stderr || stdout || String(e.message || '')).slice(-2000);
|
|
425
|
+
fail(`prerender crashed for ${route}${detail ? `:\n${detail}` : ''}`);
|
|
426
|
+
}
|
|
427
|
+
const idx = out.lastIndexOf(PRERENDER_MARKER);
|
|
428
|
+
const jsonText = idx >= 0 ? out.slice(idx + PRERENDER_MARKER.length) : out;
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(jsonText);
|
|
431
|
+
} catch {
|
|
432
|
+
fail(`prerender returned non-JSON for ${route}: ${jsonText.trim().slice(0, 200)}`);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// ---------- 3. dist skeleton + prerendered pages ----------
|
|
437
|
+
const hotFile = join(appDir, 'public/hot');
|
|
438
|
+
const hotAside = join(appDir, 'public/hot.edge-build-aside');
|
|
439
|
+
if (existsSync(hotFile)) {
|
|
440
|
+
renameSync(hotFile, hotAside);
|
|
441
|
+
console.log(' (moved public/hot aside for the build)');
|
|
442
|
+
}
|
|
443
|
+
// Clean dist/. On Windows a running `wrangler dev` (or editor) can hold a handle on
|
|
444
|
+
// dist/ or a subdirectory: file deletion still works, so fall back to wiping the
|
|
445
|
+
// contents and keeping the locked directory shells — the build rewrites everything
|
|
446
|
+
// fresh and the watcher picks the changes up.
|
|
447
|
+
for (const entry of readdirSync(appDir)) {
|
|
448
|
+
if (entry.startsWith('dist.old-')) {
|
|
449
|
+
try {
|
|
450
|
+
rmSync(join(appDir, entry), { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
|
451
|
+
} catch {
|
|
452
|
+
/* still locked — try again next build */
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
rmSync(dist, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
458
|
+
} catch {
|
|
459
|
+
if (existsSync(dist)) {
|
|
460
|
+
for (const entry of readdirSync(dist)) {
|
|
461
|
+
try {
|
|
462
|
+
rmSync(join(dist, entry), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
463
|
+
} catch {
|
|
464
|
+
console.log(
|
|
465
|
+
` (warning: ${relative(appDir, join(dist, entry))} is locked by a running process — restart the dev server if build output looks stale)`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
mkdirSync(join(dist, 'public'), { recursive: true });
|
|
472
|
+
mkdirSync(join(dist, 'src'), { recursive: true });
|
|
473
|
+
|
|
474
|
+
const writePage = (file, html) => {
|
|
475
|
+
const rel = html.replace(originRe, '/');
|
|
476
|
+
const outPath = join(dist, 'public', file);
|
|
477
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
478
|
+
writeFileSync(outPath, rel);
|
|
479
|
+
return { bytes: rel.length, fixed: (html.match(originRe) || []).length };
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const snapshots = [];
|
|
483
|
+
const partials = [];
|
|
484
|
+
const needsPort = [];
|
|
485
|
+
const uncompiledExtraNonPost = [];
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
// 3a. manual static pages (registry.json → "static")
|
|
489
|
+
for (const [route, file] of staticMap) {
|
|
490
|
+
const res = prerender(route);
|
|
491
|
+
if (res.status < 200 || res.status >= 300) {
|
|
492
|
+
const rawDetail = res.error ? String(res.error) : (res.body ? String(res.body) : '');
|
|
493
|
+
const detail = res.error ? `: ${res.error}` : (res.body ? `: ${String(res.body).slice(0, 150).replace(/\n/g, ' ')}` : '');
|
|
494
|
+
note({ route, area: 'route', severity: BLOCK, cause: `static entry "${route}" returned HTTP ${res.status}${detail}`, fix: rawDetail.includes('key:generate') ? 'run `php artisan key:generate` in the app' : isRuntimeBindingError(rawDetail) ? 'this static entry reads a D1 binding the build cannot reach -- a static entry must prerender clean, so give the app a local sqlite fallback for the build (config/database.php) or make the route dynamic' : rawDetail.includes('database') ? 'prepare the DB: touch database/database.sqlite && php artisan migrate --force (the build prerenders every page it serves)' : 'a deployed build will prerender this route too -- fix the exception the app throws for bare GET `/` first' });
|
|
495
|
+
fail(`static route ${route} returned HTTP ${res.status}${detail}`);
|
|
496
|
+
}
|
|
497
|
+
const { bytes, fixed } = writePage(file, res.body);
|
|
498
|
+
console.log(`• static ${route} -> public/${file} (${bytes} bytes, ${fixed} URL(s) made relative)`);
|
|
499
|
+
if (otherMethods(route).length) partials.push({ route, methods: otherMethods(route) });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 3b. auto-snapshot: clean GET routes appear on the edge on the next deploy
|
|
503
|
+
const handled = new Set([
|
|
504
|
+
...staticMap.map(([r]) => r),
|
|
505
|
+
...portEntries.map(([r]) => r),
|
|
506
|
+
...viewMap.map(([r]) => r),
|
|
507
|
+
...ignoreMap.map(([r]) => r),
|
|
508
|
+
]);
|
|
509
|
+
for (const route of routes) {
|
|
510
|
+
if (handled.has(route)) continue;
|
|
511
|
+
if (!autoSnapshot) {
|
|
512
|
+
needsPort.push({ route, reason: 'unhandled (autoSnapshot: false)' });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const methods = methodsFor.get(route) || new Set();
|
|
516
|
+
const others = otherMethods(route);
|
|
517
|
+
if (!methods.has('GET')) {
|
|
518
|
+
needsPort.push({ route, reason: `no GET (${[...methods].join(', ') || '?'})` });
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (route.includes('{')) {
|
|
522
|
+
needsPort.push({ route, reason: 'dynamic segment — needs a port' });
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
// A controller route of the APP is compiled, not snapshotted: the build-time
|
|
526
|
+
// page is the fallback for a route the build cannot compile. Snapshotting
|
|
527
|
+
// it answered a stale page for anything the request decides (a query
|
|
528
|
+
// string, a row created during the run) while the compiled controller was
|
|
529
|
+
// never reached - the shadow compared the baked page and called it parity.
|
|
530
|
+
//
|
|
531
|
+
// A FRAMEWORK controller is different: `Illuminate\...` is not the app's
|
|
532
|
+
// code and the build never compiles it (the composer autoload cannot even
|
|
533
|
+
// resolve it), so its snapshot is the right answer - Laravel's own `/up`
|
|
534
|
+
// health route is exactly that.
|
|
535
|
+
{
|
|
536
|
+
const action = actionForRoute(route);
|
|
537
|
+
const actionClass = action && action.includes('@') ? action.slice(0, action.lastIndexOf('@')) : action;
|
|
538
|
+
const isAppController =
|
|
539
|
+
!!actionClass && actionClass !== 'Closure' && !/^Illuminate\\/.test(actionClass);
|
|
540
|
+
if (isAppController) {
|
|
541
|
+
needsPort.push({ route, reason: 'a controller serves it — compiled, not snapshotted' });
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const res = prerender(route);
|
|
546
|
+
const isHtml = String(res.content_type || '').includes('text/html');
|
|
547
|
+
if (res.status >= 200 && res.status < 300 && isHtml) {
|
|
548
|
+
const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
|
|
549
|
+
const { bytes, fixed } = writePage(file, res.body);
|
|
550
|
+
snapshots.push(route);
|
|
551
|
+
console.log(`• snapshot ${route} -> public/${file} (${bytes} bytes, ${fixed} URL(s) made relative)`);
|
|
552
|
+
if (others.length) partials.push({ route, methods: others });
|
|
553
|
+
} else if (res.status >= 200 && res.status < 300) {
|
|
554
|
+
needsPort.push({ route, reason: `HTTP ${res.status} non-HTML (${res.content_type || '?'})` });
|
|
555
|
+
} else {
|
|
556
|
+
const to = res.location ? ` -> ${res.location}` : '';
|
|
557
|
+
const why = res.error ? `: ${res.error}` : '';
|
|
558
|
+
needsPort.push({ route, reason: `HTTP ${res.status}${to}${why}` });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} finally {
|
|
562
|
+
if (existsSync(hotAside)) renameSync(hotAside, hotFile);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------- 2b. runtime config (compiled HTTP kernel) ----------
|
|
566
|
+
// kernel/http.mjs reads a FLAT dotted config. Anything the compiled rung cannot
|
|
567
|
+
// honour fails the build here instead of producing a silently-degraded worker.
|
|
568
|
+
console.log('• dumping runtime config via `tools/config-dump.php`');
|
|
569
|
+
let runtimeConfig;
|
|
570
|
+
try {
|
|
571
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'config-dump.php')], {
|
|
572
|
+
cwd: appDir,
|
|
573
|
+
env: childEnv(),
|
|
574
|
+
maxBuffer: 1 << 24,
|
|
575
|
+
}).toString('utf8');
|
|
576
|
+
try {
|
|
577
|
+
runtimeConfig = JSON.parse(raw);
|
|
578
|
+
} catch {
|
|
579
|
+
const tail = raw.trim() ? raw.trim().split('\n').slice(-15).map((l) => ' ' + l).join('\n') : ' (empty)';
|
|
580
|
+
note({
|
|
581
|
+
severity: BLOCK,
|
|
582
|
+
area: 'config',
|
|
583
|
+
cause: 'runtime config dump failed — the app did not return the JSON config (it failed to boot)',
|
|
584
|
+
fix: 'the PHP error below is the cause; check APP_KEY, session.driver, and config file validity',
|
|
585
|
+
});
|
|
586
|
+
fail(
|
|
587
|
+
'config dump failed — the app did not return the JSON runtime config, so it failed to boot\n' +
|
|
588
|
+
' PHP output:\n' + tail
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
} catch (e) {
|
|
592
|
+
const msg = String(e.stderr || e.message || '').trim().slice(0, 800);
|
|
593
|
+
note({
|
|
594
|
+
severity: BLOCK,
|
|
595
|
+
area: 'config',
|
|
596
|
+
cause: 'runtime config dump failed' + (msg ? ': ' + msg : ''),
|
|
597
|
+
fix: 'check APP_KEY, session.driver, and config file validity; ensure required keys are present and valid JSON',
|
|
598
|
+
});
|
|
599
|
+
fail('config dump failed: ' + msg);
|
|
600
|
+
}
|
|
601
|
+
{
|
|
602
|
+
const problems = [];
|
|
603
|
+
const problem = (cause, fix) => { problems.push(cause); note({ route: null, area: 'config', severity: BLOCK, cause, fix }); };
|
|
604
|
+
if (!runtimeConfig['app.key']) problem('app.key is empty', 'run `php artisan key:generate` (the edge signs cookies and sessions with it)');
|
|
605
|
+
const driver = runtimeConfig['session.driver'];
|
|
606
|
+
if (driver !== 'database' && driver !== 'cookie') {
|
|
607
|
+
problem(`session.driver '${driver}' is not supported by the compiled rung (database/cookie only)`);
|
|
608
|
+
}
|
|
609
|
+
if (runtimeConfig['session.encrypt']) {
|
|
610
|
+
problem('SESSION_ENCRYPT=true (EncryptedStore) is not supported by the compiled rung yet');
|
|
611
|
+
}
|
|
612
|
+
if (runtimeConfig['cookies.serialize']) {
|
|
613
|
+
problem('EncryptCookies::$serialize must be false (cookie serialization is not ported)');
|
|
614
|
+
}
|
|
615
|
+
if ((runtimeConfig['hashing.driver'] || 'bcrypt') !== 'bcrypt') {
|
|
616
|
+
problem(`hashing.driver '${runtimeConfig['hashing.driver']}' is not supported (bcrypt only)`);
|
|
617
|
+
}
|
|
618
|
+
const thirdPartyGuards = Object.entries(runtimeConfig)
|
|
619
|
+
.filter(([k, v]) => k.startsWith('auth.guards.') && v && v.driver && v.driver !== 'session')
|
|
620
|
+
.map(([k, v]) => `${k} (driver '${v.driver}')`);
|
|
621
|
+
if (thirdPartyGuards.length) {
|
|
622
|
+
problem(`guard(s) with a non-session driver: ${thirdPartyGuards.join(', ')} (only the session guard is ported)`);
|
|
623
|
+
}
|
|
624
|
+
if (problems.length) {
|
|
625
|
+
fail('unsupported runtime config (the compiled rung would diverge from PHP):\n - ' + problems.join('\n - '));
|
|
626
|
+
}
|
|
627
|
+
writeFileSync(
|
|
628
|
+
join(dist, 'src', 'config.generated.mjs'),
|
|
629
|
+
'// generated by build.mjs — do not edit\n' + 'export default ' + JSON.stringify(runtimeConfig, null, 2) + ';\n'
|
|
630
|
+
);
|
|
631
|
+
console.log(` -> dist/src/config.generated.mjs (session: ${driver}, guard: ${runtimeConfig['auth.defaults.guard']})`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// The CORS paths (config/cors.php): HandleCors adds the CORS headers to the
|
|
635
|
+
// responses of the paths it matches, and answers the preflight with a 204.
|
|
636
|
+
// The compiled worker does not add the CORS headers, so the routes under a
|
|
637
|
+
// CORS path are skipped (a silent answer without the headers would break the
|
|
638
|
+
// browser's CORS - a divergence the build must not ship).
|
|
639
|
+
const corsPaths = (() => {
|
|
640
|
+
const raw = runtimeConfig['cors.paths'];
|
|
641
|
+
if (!raw || typeof raw !== 'object') return [];
|
|
642
|
+
const out = [];
|
|
643
|
+
for (const value of Object.values(raw)) {
|
|
644
|
+
if (Array.isArray(value)) {
|
|
645
|
+
for (const p of value) if (typeof p === 'string') out.push(p);
|
|
646
|
+
} else if (typeof value === 'string') {
|
|
647
|
+
out.push(value);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
})();
|
|
652
|
+
// Laravel's Str::is(): the pattern may end with '*' (a prefix match).
|
|
653
|
+
const corsPathFor = (uri) => {
|
|
654
|
+
const clean = (s) => '/' + String(s == null ? '' : s).replace(/^\/+/, '').replace(/\/+$/, '');
|
|
655
|
+
const path = clean(uri);
|
|
656
|
+
for (const pattern of corsPaths) {
|
|
657
|
+
const p = clean(pattern);
|
|
658
|
+
if (p === '/') return pattern;
|
|
659
|
+
if (p.endsWith('*')) {
|
|
660
|
+
if (path.startsWith(p.slice(0, -1))) return pattern;
|
|
661
|
+
} else if (path === p) {
|
|
662
|
+
return pattern;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// The routes that dispatch a queued job, and the cron triggers the schedule
|
|
669
|
+
// produced: the queue check at the end reads both.
|
|
670
|
+
const queuePushes = [];
|
|
671
|
+
let cronTriggers = [];
|
|
672
|
+
|
|
673
|
+
// ---------- 3b-bis. the app's schedule -> Cloudflare Cron Triggers ----------
|
|
674
|
+
// The edge cannot run an artisan command, but it can *trigger* it: the schedule
|
|
675
|
+
// becomes a cron trigger (the padanan — Free allows 5 triggers per account) and
|
|
676
|
+
// the due event is queued for the app runtime that still exists.
|
|
677
|
+
{
|
|
678
|
+
console.log('• dumping the app schedule via `tools/schedule-dump.php`');
|
|
679
|
+
let schedule = { events: [] };
|
|
680
|
+
try {
|
|
681
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'schedule-dump.php')], {
|
|
682
|
+
cwd: appDir,
|
|
683
|
+
env: childEnv(),
|
|
684
|
+
encoding: 'utf8',
|
|
685
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
686
|
+
}).toString('utf8');
|
|
687
|
+
schedule = JSON.parse(raw);
|
|
688
|
+
} catch (e) {
|
|
689
|
+
console.log(
|
|
690
|
+
` schedule dump failed — the artifact ships an empty schedule: ${String(e.stderr || e.message || '')
|
|
691
|
+
.trim()
|
|
692
|
+
.slice(0, 200)}`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const events = Array.isArray(schedule.events) ? schedule.events : [];
|
|
696
|
+
writeFileSync(
|
|
697
|
+
join(dist, 'src', 'schedule.generated.mjs'),
|
|
698
|
+
'// generated by build.mjs — do not edit\n' +
|
|
699
|
+
'export const events = ' + JSON.stringify(events, null, 2) + ';\n' +
|
|
700
|
+
'export default { events };\n'
|
|
701
|
+
);
|
|
702
|
+
// The Cloudflare cron trigger list (5 triggers per account on Free).
|
|
703
|
+
const triggers = events.filter((e) => e && e.cron_triggerable && e.expression).map((e) => e.expression);
|
|
704
|
+
const uniqueTriggers = [...new Set(triggers)];
|
|
705
|
+
cronTriggers = uniqueTriggers;
|
|
706
|
+
writeFileSync(
|
|
707
|
+
join(dist, 'cron.json'),
|
|
708
|
+
JSON.stringify({ crons: uniqueTriggers, events: events.map((e) => ({ name: e.name, expression: e.expression })) }, null, 2) + '\n'
|
|
709
|
+
);
|
|
710
|
+
const notTriggerable = events.filter((e) => e && !e.cron_triggerable);
|
|
711
|
+
console.log(
|
|
712
|
+
` -> dist/src/schedule.generated.mjs (${events.length} event(s), ${uniqueTriggers.length} cron trigger(s))`
|
|
713
|
+
);
|
|
714
|
+
if (uniqueTriggers.length) {
|
|
715
|
+
console.log(
|
|
716
|
+
` -> dist/cron.json (add the crons to wrangler.jsonc: {"triggers":{"crons":[${uniqueTriggers
|
|
717
|
+
.map((c) => JSON.stringify(c))
|
|
718
|
+
.join(', ')}]}})`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
for (const e of notTriggerable) {
|
|
722
|
+
console.log(` ! ${e.name}: ${e.reason || 'not triggerable at the edge'}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Eloquent auth providers name their model as a string; the compiled rung needs the
|
|
727
|
+
// generated Model class, so those FQCNs are always dumped into models.generated.mjs.
|
|
728
|
+
const authProviderFqcns = Object.entries(runtimeConfig)
|
|
729
|
+
.filter(([k, v]) => k.startsWith('auth.providers.') && v && v.driver === 'eloquent' && typeof v.model === 'string')
|
|
730
|
+
.map(([, v]) => v.model);
|
|
731
|
+
|
|
732
|
+
// ---------- 3c. compile view controllers ----------
|
|
733
|
+
const slugifyRoute = (route) =>
|
|
734
|
+
route.replace(new RegExp('^/'), '').replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'home';
|
|
735
|
+
|
|
736
|
+
// Does the app bind a Cloudflare product under a given name? `product` is the
|
|
737
|
+
// wrangler key ('r2_buckets' | 'd1_databases'). `wrangler.jsonc` is JSON with
|
|
738
|
+
// comments, so the comments are stripped before parsing.
|
|
739
|
+
function hasBinding(appRoot, product, name) {
|
|
740
|
+
const jsonc = join(appRoot, 'wrangler.jsonc');
|
|
741
|
+
if (existsSync(jsonc)) {
|
|
742
|
+
try {
|
|
743
|
+
const text = readFileSync(jsonc, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
744
|
+
const cfg = JSON.parse(text);
|
|
745
|
+
const list = cfg[product];
|
|
746
|
+
if (Array.isArray(list) && list.some((b) => b && b.binding === name)) return true;
|
|
747
|
+
} catch {
|
|
748
|
+
/* a wrangler.jsonc we cannot parse is not a binding we can rely on */
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const toml = join(appRoot, 'wrangler.toml');
|
|
752
|
+
if (existsSync(toml)) {
|
|
753
|
+
try {
|
|
754
|
+
const text = readFileSync(toml, 'utf8');
|
|
755
|
+
// Every [[product]] block (up to the next table): does one bind the name?
|
|
756
|
+
return text
|
|
757
|
+
.split(new RegExp('\\[\\[' + product + '\\]\\]'))
|
|
758
|
+
.slice(1)
|
|
759
|
+
.some((b) => new RegExp('binding\\s*=\\s*"' + name + '"').test(b.split(/\n\s*\[/)[0]));
|
|
760
|
+
} catch {
|
|
761
|
+
/* ignore */
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
mkdirSync(join(dist, 'src', 'controllers'), { recursive: true });
|
|
768
|
+
mkdirSync(join(dist, 'src', 'views'), { recursive: true });
|
|
769
|
+
mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
|
|
770
|
+
|
|
771
|
+
const controllerByRoute = new Map();
|
|
772
|
+
|
|
773
|
+
for (const [route, view] of viewMap) {
|
|
774
|
+
const slug = slugifyRoute(route);
|
|
775
|
+
const dumpPath = join(cacheDir, view + '.compiled.json');
|
|
776
|
+
console.log(`compiling view ${view} for ${route}`);
|
|
777
|
+
execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), view, dumpPath, route], {
|
|
778
|
+
cwd: appDir,
|
|
779
|
+
env: childEnv(),
|
|
780
|
+
});
|
|
781
|
+
const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
|
|
782
|
+
if (!dump || typeof dump.compiled !== 'string') fail('compile-view produced no compiled PHP for ' + route);
|
|
783
|
+
|
|
784
|
+
const viewModulePath = join(dist, 'src', 'views', view + '.mjs');
|
|
785
|
+
execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], { cwd: appDir });
|
|
786
|
+
|
|
787
|
+
const contextPath = join(dist, 'src', 'contexts', view + '.mjs');
|
|
788
|
+
writeFileSync(contextPath, 'export default ' + JSON.stringify(dump.context) + ';\n');
|
|
789
|
+
|
|
790
|
+
const controllerPath = join(dist, 'src', 'controllers', slug + '.mjs');
|
|
791
|
+
writeFileSync(
|
|
792
|
+
controllerPath,
|
|
793
|
+
[
|
|
794
|
+
"import main from '../views/" + view + ".mjs';",
|
|
795
|
+
"import context from '../contexts/" + view + ".mjs';",
|
|
796
|
+
"import { buildRt } from '../runtime/rt-from-context.mjs';",
|
|
797
|
+
"import { html } from '../../kernel/response.mjs';",
|
|
798
|
+
'',
|
|
799
|
+
'export default function handle(ctx, env) {',
|
|
800
|
+
' const rt = buildRt(context, { strict: true });',
|
|
801
|
+
' main(rt);',
|
|
802
|
+
' return html(rt.__output);',
|
|
803
|
+
'}',
|
|
804
|
+
'',
|
|
805
|
+
].join('\n')
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
controllerByRoute.set(route, slug);
|
|
809
|
+
console.log(` -> dist/src/controllers/${slug}.mjs`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ---------- 3c1. compile error views ----------
|
|
813
|
+
// Laravel renders `errors::<code>`: the app's resources/views/errors/<code>
|
|
814
|
+
// when it exists, else the FRAMEWORK's own view
|
|
815
|
+
// (vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/views - the
|
|
816
|
+
// Handler registers that namespace per render, RegisterErrorViewPaths). Both
|
|
817
|
+
// are Blade, so both compile here; a status with neither keeps the
|
|
818
|
+
// status-only page (and the shadow reports it as a divergence).
|
|
819
|
+
const errorViewDir = join(appDir, 'resources', 'views', 'errors');
|
|
820
|
+
const appErrorCodes = existsSync(errorViewDir)
|
|
821
|
+
? readdirSync(errorViewDir)
|
|
822
|
+
.filter((f) => f.endsWith('.blade.php'))
|
|
823
|
+
.map((f) => f.replace(/\.blade\.php$/, ''))
|
|
824
|
+
.sort()
|
|
825
|
+
: [];
|
|
826
|
+
// The framework's error views EXTEND `errors::minimal`, which extends
|
|
827
|
+
// `errors::layout`: the whole closure compiles, so the layout is in the list.
|
|
828
|
+
const frameworkErrorCodes = [
|
|
829
|
+
'401', '402', '403', '404', '419', '429', '500', '503',
|
|
830
|
+
'layout', 'minimal',
|
|
831
|
+
];
|
|
832
|
+
const errorViewCodes = [...new Set([...appErrorCodes, ...frameworkErrorCodes])].sort();
|
|
833
|
+
// Which of the codes come from the framework (the app did not override them):
|
|
834
|
+
// their view NAME is the namespaced one.
|
|
835
|
+
const frameworkOnly = new Set(errorViewCodes.filter((c) => !appErrorCodes.includes(c)));
|
|
836
|
+
const errorViewImports = [];
|
|
837
|
+
const errorViewCases = [];
|
|
838
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
839
|
+
if (errorViewCodes.length) {
|
|
840
|
+
console.log(`• error views: compiling ${errorViewCodes.length} (${errorViewCodes.join(', ')})`);
|
|
841
|
+
mkdirSync(join(dist, 'src', 'views'), { recursive: true });
|
|
842
|
+
mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
|
|
843
|
+
for (const code of errorViewCodes) {
|
|
844
|
+
const viewName = frameworkOnly.has(code) ? 'errors::' + code : 'errors.' + code;
|
|
845
|
+
// The name the compiled Blade calls back (`$__env->make('errors::minimal')`)
|
|
846
|
+
// and the name the renderer keys on differ for the framework views: the
|
|
847
|
+
// runtime registry uses the `errors::<name>` form the source writes.
|
|
848
|
+
const registryName = frameworkOnly.has(code) ? 'errors::' + code : viewName;
|
|
849
|
+
const dumpPath = join(cacheDir, 'errors.' + code + '.compiled.json');
|
|
850
|
+
console.log(` compiling error view ${viewName}`);
|
|
851
|
+
let dump;
|
|
852
|
+
try {
|
|
853
|
+
execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), viewName, dumpPath, '/'], {
|
|
854
|
+
cwd: appDir,
|
|
855
|
+
env: childEnv(),
|
|
856
|
+
});
|
|
857
|
+
dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
|
|
858
|
+
} catch (e) {
|
|
859
|
+
note({
|
|
860
|
+
severity: BLOCK,
|
|
861
|
+
area: 'route',
|
|
862
|
+
cause: `error view ${code} failed to compile`,
|
|
863
|
+
fix: 'check the view source compiles; ensure compile-view.php can write its cache output',
|
|
864
|
+
});
|
|
865
|
+
fail('compile-view failed for ' + code + ': ' + String(e.stderr || e.message || '').trim().slice(0, 800));
|
|
866
|
+
}
|
|
867
|
+
if (!dump || typeof dump.compiled !== 'string') fail('compile-view produced no compiled PHP for ' + viewName);
|
|
868
|
+
|
|
869
|
+
const viewModulePath = join(dist, 'src', 'views', 'errors.' + code + '.mjs');
|
|
870
|
+
execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], { cwd: appDir });
|
|
871
|
+
|
|
872
|
+
const contextPath = join(dist, 'src', 'contexts', 'errors.' + code + '.mjs');
|
|
873
|
+
writeFileSync(contextPath, 'export default ' + JSON.stringify(dump.context) + ';\n');
|
|
874
|
+
|
|
875
|
+
const importName = 'errorView_' + String(code).replace(/[^A-Za-z0-9_]/g, '_');
|
|
876
|
+
errorViewImports.push(`import ${importName}Main from './views/errors.${code}.mjs';`);
|
|
877
|
+
errorViewImports.push(`import ${importName}Context from './contexts/errors.${code}.mjs';`);
|
|
878
|
+
errorViewCases.push({ code, importName, registryName });
|
|
879
|
+
console.log(` -> dist/src/views/errors.${code}.mjs`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const errorViewModulePath = join(dist, 'src', 'error-views.generated.mjs');
|
|
884
|
+
const errorViewLines = ['// generated by build.mjs � do not edit', ...errorViewImports];
|
|
885
|
+
if (errorViewCases.length) {
|
|
886
|
+
errorViewLines.push("import { buildRt } from './runtime/rt-from-context.mjs';");
|
|
887
|
+
errorViewLines.push('');
|
|
888
|
+
// The layout registry the compiled error views resolve through
|
|
889
|
+
// (`$__env->make('errors::minimal')`): every non-status view compiled above.
|
|
890
|
+
errorViewLines.push('const errorViewRegistry = {};');
|
|
891
|
+
for (const { code, importName } of errorViewCases) {
|
|
892
|
+
if (/^[0-9]/.test(code)) continue;
|
|
893
|
+
errorViewLines.push(
|
|
894
|
+
`errorViewRegistry[${JSON.stringify('errors::' + code)}] = { main: ${importName}Main, context: ${importName}Context };`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
errorViewLines.push('');
|
|
898
|
+
errorViewLines.push('export const errorViews = {');
|
|
899
|
+
for (const { code, importName, registryName } of errorViewCases) {
|
|
900
|
+
if (!/^[0-9]/.test(code)) continue;
|
|
901
|
+
errorViewLines.push(` ${JSON.stringify(code)}: (status, request, ctx, error) => {`);
|
|
902
|
+
// The exception the view sees: Laravel's framework views call
|
|
903
|
+
// `$exception->getMessage()` (403/500), and the compiled handler's error is
|
|
904
|
+
// a plain JS Error with `.status` - so it is wrapped in the exception
|
|
905
|
+
// surface those views use (Laravel's Throwable API).
|
|
906
|
+
errorViewLines.push(
|
|
907
|
+
` const ex = error == null ? null : { message: String(error.message == null ? '' : error.message), status: Number(error.status) || Number(status) || 500,` +
|
|
908
|
+
` getMessage() { return String(this.message); }, getCode() { return 0; }, getStatusCode() { return this.status; } };`
|
|
909
|
+
);
|
|
910
|
+
errorViewLines.push(
|
|
911
|
+
` const rt = buildRt(${importName}Context, { strict: true, views: { ...errorViewRegistry }, data: { exception: ex, errors: {} } });`
|
|
912
|
+
);
|
|
913
|
+
errorViewLines.push(` ${importName}Main(rt);`);
|
|
914
|
+
// PhpEngine::evaluatePath() returns ltrim(ob_get_clean()): a rendered view
|
|
915
|
+
// never starts with whitespace (Laravel's own 404 body starts with
|
|
916
|
+
// "<!DOCTYPE", not a newline).
|
|
917
|
+
errorViewLines.push(' return String(rt.__output).replace(/^[ \\t\\n\\r\\0\\x0B]+/, "");');
|
|
918
|
+
errorViewLines.push(' },');
|
|
919
|
+
}
|
|
920
|
+
errorViewLines.push('};');
|
|
921
|
+
} else {
|
|
922
|
+
errorViewLines.push('export const errorViews = {};');
|
|
923
|
+
}
|
|
924
|
+
writeFileSync(errorViewModulePath, errorViewLines.join('\n') + '\n');
|
|
925
|
+
|
|
926
|
+
const errorRendererPath = join(dist, 'src', 'error-renderer.mjs');
|
|
927
|
+
writeFileSync(
|
|
928
|
+
errorRendererPath,
|
|
929
|
+
[
|
|
930
|
+
"// generated by build.mjs � do not edit",
|
|
931
|
+
"import { errorViews } from './error-views.generated.mjs';",
|
|
932
|
+
'',
|
|
933
|
+
'export function renderErrorView(status, request, ctx, error) {',
|
|
934
|
+
' const code = String(status);',
|
|
935
|
+
' let key = code;',
|
|
936
|
+
' if (!Object.prototype.hasOwnProperty.call(errorViews, key)) {',
|
|
937
|
+
' key = String(Math.floor(Number(status) / 100)) + "xx";',
|
|
938
|
+
' }',
|
|
939
|
+
' const render = errorViews[key];',
|
|
940
|
+
' if (typeof render !== "function") return null;',
|
|
941
|
+
' try {',
|
|
942
|
+
' return render(Number(status), request, ctx, error);',
|
|
943
|
+
' } catch (e) {',
|
|
944
|
+
' return null;',
|
|
945
|
+
' }',
|
|
946
|
+
'}',
|
|
947
|
+
'',
|
|
948
|
+
].join('\n')
|
|
949
|
+
);
|
|
950
|
+
console.log(` -> dist/src/error-renderer.mjs (${errorViewCases.length} view(s))`);
|
|
951
|
+
|
|
952
|
+
for (const [route, name] of portEntries) {
|
|
953
|
+
const slug = String(name);
|
|
954
|
+
const controllerPath = join(dist, 'src', 'controllers', slug + '.mjs');
|
|
955
|
+
writeFileSync(
|
|
956
|
+
controllerPath,
|
|
957
|
+
[
|
|
958
|
+
"export { default } from '../ports/" + name + ".mjs';",
|
|
959
|
+
'',
|
|
960
|
+
].join('\n')
|
|
961
|
+
);
|
|
962
|
+
controllerByRoute.set(route, slug);
|
|
963
|
+
console.log(`port controller ${route} -> dist/src/controllers/${slug}.mjs`);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---------- 3c2. transpile controllers (single-method routes that still need a port) ----------
|
|
967
|
+
// A route only leaves the "needs a port" list when its controller method was fully
|
|
968
|
+
// transpiled at build time. The uri's main verb (GET, else POST) plus any extra
|
|
969
|
+
// verb (Breeze's GET+POST /login) are each compiled to their own "METHOD uri"
|
|
970
|
+
// handler / table entry. Anything outside the supported subset (auth flows,
|
|
971
|
+
// relations, unknown calls, non-scalar params, ...) stays blocked under --strict
|
|
972
|
+
// or keeps needing a port — the build never silently degrades.
|
|
973
|
+
const controllerRoutes = new Map(); // route -> { slug, fqcn, method }
|
|
974
|
+
// Does any compiled controller use the Storage facade? (decides the object store)
|
|
975
|
+
let usesStorage = false;
|
|
976
|
+
const verbControllers = []; // extra-verb handlers: { route, verb, slug, fqcn, method }
|
|
977
|
+
// The HTTP verbs the compiled rung can serve. OPTIONS is not one of them (the
|
|
978
|
+
// platform answers the CORS preflight, not the app).
|
|
979
|
+
const COMPILABLE_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
980
|
+
{
|
|
981
|
+
const methodsOk = (route) => {
|
|
982
|
+
const main = mainVerbFor(route);
|
|
983
|
+
// The compiled rung serves GET/POST/PUT/PATCH/DELETE; OPTIONS is not one
|
|
984
|
+
// of them (the platform answers the CORS preflight).
|
|
985
|
+
return COMPILABLE_METHODS.has(main);
|
|
986
|
+
};
|
|
987
|
+
const targets = [];
|
|
988
|
+
for (const { route } of needsPort) {
|
|
989
|
+
if (!methodsOk(route)) continue;
|
|
990
|
+
const action = actionForRoute(route);
|
|
991
|
+
if (!action || action === 'Closure') continue;
|
|
992
|
+
const mwList = middlewareForRoute(route) || [];
|
|
993
|
+
const blocking = mwList.filter((m) => !isHonouredMiddleware(m));
|
|
994
|
+
if (blocking.length) {
|
|
995
|
+
console.log(` skip controller ${route}: middleware [${blocking.join(', ')}] not transpiled — write a port`);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const at = action.lastIndexOf('@');
|
|
999
|
+
const fqcn = at >= 0 ? action.slice(0, at) : action;
|
|
1000
|
+
const method = at >= 0 ? action.slice(at + 1) : '__invoke';
|
|
1001
|
+
if (!/^[A-Za-z_][A-Za-z0-9_\\]*$/.test(fqcn) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(method)) continue;
|
|
1002
|
+
targets.push({ route, verb: mainVerbFor(route), fqcn, method });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Extra verbs on a uri whose main verb is already handled by a snapshot / view /
|
|
1006
|
+
// static page (Breeze's GET+POST /login is the canonical case). Each compiles to
|
|
1007
|
+
// its own handler keyed "METHOD uri"; a verb that cannot compile stays a partial
|
|
1008
|
+
// and still blocks the strict build.
|
|
1009
|
+
const extraTargets = [];
|
|
1010
|
+
// Verbs the compiled rung can serve as a controller. OPTIONS is not one of them
|
|
1011
|
+
// (the browser/CORS preflight is answered by the platform, not the app).
|
|
1012
|
+
for (const route of routes) {
|
|
1013
|
+
for (const verb of extraVerbsFor(route)) {
|
|
1014
|
+
const meta = metaForVerb(route, verb);
|
|
1015
|
+
const action = meta && meta.action;
|
|
1016
|
+
if (!COMPILABLE_METHODS.has(String(verb).toUpperCase())) {
|
|
1017
|
+
const alreadyTracked = partials.some((p) => p.route === route && p.methods.includes(verb));
|
|
1018
|
+
const ported = portEntries.some(([r]) => r === route);
|
|
1019
|
+
const ignored = ignoreMap.some(([r]) => r === route);
|
|
1020
|
+
if (!alreadyTracked && !ported && !ignored) {
|
|
1021
|
+
uncompiledExtraNonPost.push({ route, verb });
|
|
1022
|
+
}
|
|
1023
|
+
console.log(` skip ${verb} ${route}: only GET/POST/PUT/PATCH/DELETE handlers are compiled — write a port`);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (!action || action === 'Closure') continue;
|
|
1027
|
+
const mwList = (meta && meta.middleware) || [];
|
|
1028
|
+
const blocking = mwList.filter((m) => !isHonouredMiddleware(m));
|
|
1029
|
+
if (blocking.length) {
|
|
1030
|
+
console.log(` skip ${verb} ${route}: middleware [${blocking.join(', ')}] not transpiled — write a port`);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const at = action.lastIndexOf('@');
|
|
1034
|
+
const fqcn = at >= 0 ? action.slice(0, at) : action;
|
|
1035
|
+
const method = at >= 0 ? action.slice(at + 1) : '__invoke';
|
|
1036
|
+
if (!/^[A-Za-z_][A-Za-z0-9_\\]*$/.test(fqcn) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(method)) continue;
|
|
1037
|
+
extraTargets.push({ route, verb, fqcn, method });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
for (const t of extraTargets) {
|
|
1041
|
+
if (!targets.some((x) => x.route === t.route && x.verb === t.verb)) targets.push(t);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (targets.length) {
|
|
1045
|
+
console.log(`• controller routes: trying to transpile ${targets.length} route(s)`);
|
|
1046
|
+
|
|
1047
|
+
const formatControllerError = (e) => {
|
|
1048
|
+
const raw = String(e.stderr || e.stdout || e.message || '').trim();
|
|
1049
|
+
try {
|
|
1050
|
+
const errs = JSON.parse(raw);
|
|
1051
|
+
if (Array.isArray(errs) && errs.length) {
|
|
1052
|
+
return errs
|
|
1053
|
+
.map((x) => `${x.code || 'LW'} ${x.message || ''}${x.line ? ` (line ${x.line})` : ''}`)
|
|
1054
|
+
.join('; ');
|
|
1055
|
+
}
|
|
1056
|
+
} catch {
|
|
1057
|
+
/* not a JSON error payload */
|
|
1058
|
+
}
|
|
1059
|
+
return raw.slice(0, 300) || 'unknown error';
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
// resolve controller PHP files through the app's composer PSR-4 map
|
|
1063
|
+
let psr4 = {};
|
|
1064
|
+
try {
|
|
1065
|
+
const composer = JSON.parse(readFileSync(join(appDir, 'composer.json'), 'utf8'));
|
|
1066
|
+
psr4 = Object.fromEntries(
|
|
1067
|
+
Object.entries((composer.autoload && composer.autoload['psr-4']) || {}).map(([prefix, dirs]) => [
|
|
1068
|
+
prefix,
|
|
1069
|
+
Array.isArray(dirs) ? dirs : [dirs],
|
|
1070
|
+
])
|
|
1071
|
+
);
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* defaults below win */
|
|
1074
|
+
}
|
|
1075
|
+
const resolvePsr4 = (fqcn) => {
|
|
1076
|
+
let best = null;
|
|
1077
|
+
for (const [prefix, dirs] of Object.entries(psr4)) {
|
|
1078
|
+
if (fqcn.startsWith(prefix) && (!best || prefix.length > best[0].length)) best = [prefix, dirs];
|
|
1079
|
+
}
|
|
1080
|
+
if (!best) return null;
|
|
1081
|
+
const rest = fqcn.slice(best[0].length).replace(/\\/g, '/') + '.php';
|
|
1082
|
+
for (const d of best[1]) {
|
|
1083
|
+
const p = join(appDir, String(d).replace(/[\\/]+$/, ''), rest);
|
|
1084
|
+
if (existsSync(p)) return p;
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
mkdirSync(join(dist, 'src', 'controllers'), { recursive: true });
|
|
1090
|
+
mkdirSync(join(dist, 'src', 'views'), { recursive: true });
|
|
1091
|
+
mkdirSync(join(dist, 'src', 'contexts'), { recursive: true });
|
|
1092
|
+
const metaDir = join(cacheDir, 'controllers');
|
|
1093
|
+
mkdirSync(metaDir, { recursive: true });
|
|
1094
|
+
|
|
1095
|
+
// phase A: extract method metadata (params, model classes, views)
|
|
1096
|
+
const prepared = [];
|
|
1097
|
+
for (const t of targets) {
|
|
1098
|
+
const file = resolvePsr4(t.fqcn);
|
|
1099
|
+
if (!file) {
|
|
1100
|
+
console.log(` controller ${t.route}: skipped — cannot resolve ${t.fqcn} via composer autoload`);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
try {
|
|
1104
|
+
const out = execFileSync(
|
|
1105
|
+
process.execPath,
|
|
1106
|
+
[join(pkgDir, 'compiler', 'bin', 'controller.mjs'), 'extract', '--file', file, '--method', t.method],
|
|
1107
|
+
{ cwd: appDir }
|
|
1108
|
+
).toString('utf8');
|
|
1109
|
+
prepared.push({ ...t, file, meta: JSON.parse(out) });
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
console.log(` controller ${t.route}: skipped — ${formatControllerError(e)}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (prepared.length) {
|
|
1116
|
+
// phase B: model metadata (one PHP dump for every referenced class)
|
|
1117
|
+
const aliasToFqcns = new Map(); // alias -> Set(fqcn)
|
|
1118
|
+
|
|
1119
|
+
for (const p of prepared) {
|
|
1120
|
+
for (const c of p.meta.classes || []) {
|
|
1121
|
+
const set = aliasToFqcns.get(c.alias) || new Set();
|
|
1122
|
+
set.add(c.fqcn);
|
|
1123
|
+
aliasToFqcns.set(c.alias, set);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// FormRequest candidates (a class-typed parameter that is not a plain
|
|
1127
|
+
// Request). The PHP dump decides whether it really is a FormRequest.
|
|
1128
|
+
const formRequestFqcns = new Set();
|
|
1129
|
+
for (const p of prepared) {
|
|
1130
|
+
for (const param of p.meta.params || []) {
|
|
1131
|
+
if (param.inject === 'request') continue;
|
|
1132
|
+
if (!param.typeFqcn) continue;
|
|
1133
|
+
formRequestFqcns.add(param.typeFqcn);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Class constants (e.g. RouteServiceProvider::HOME) are dumped too — the
|
|
1137
|
+
// value is baked into the handler.
|
|
1138
|
+
const constantFqcns = new Set();
|
|
1139
|
+
for (const p of prepared) {
|
|
1140
|
+
for (const c of p.meta.constants || []) constantFqcns.add(c.fqcn);
|
|
1141
|
+
}
|
|
1142
|
+
// The class-typed parameters: a FormRequest (injected like a Request) or a
|
|
1143
|
+
// model (Laravel's implicit route-model binding). The model dump decides
|
|
1144
|
+
// which one it is.
|
|
1145
|
+
const paramTypeFqcns = new Set();
|
|
1146
|
+
for (const p of prepared) {
|
|
1147
|
+
for (const param of p.meta.params || []) {
|
|
1148
|
+
if (param.inject === 'request') continue;
|
|
1149
|
+
if (!param.typeFqcn) continue;
|
|
1150
|
+
paramTypeFqcns.add(param.typeFqcn);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const allFqcns = [...new Set([...aliasToFqcns.values()].flatMap((s) => [...s]))];
|
|
1154
|
+
// The model-bound parameters (Laravel's implicit route-model binding:
|
|
1155
|
+
// `show(Post $post)`): the type must be dumped too — the model and the key
|
|
1156
|
+
// it binds on.
|
|
1157
|
+
for (const fqcn of paramTypeFqcns) {
|
|
1158
|
+
if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
|
|
1159
|
+
}
|
|
1160
|
+
// Eloquent auth provider models (config('auth.providers.*.model')) must be in the
|
|
1161
|
+
// generated registry even when no compiled controller references them.
|
|
1162
|
+
for (const fqcn of authProviderFqcns) {
|
|
1163
|
+
const short = fqcn.split('\\').pop();
|
|
1164
|
+
if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
|
|
1165
|
+
aliasToFqcns.get(short).add(fqcn);
|
|
1166
|
+
if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
|
|
1167
|
+
}
|
|
1168
|
+
const modelDump = new Map();
|
|
1169
|
+
if (allFqcns.length) {
|
|
1170
|
+
console.log(`• dumping model metadata (${allFqcns.length}): ${allFqcns.join(', ')}`);
|
|
1171
|
+
try {
|
|
1172
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...allFqcns], {
|
|
1173
|
+
cwd: appDir,
|
|
1174
|
+
env: childEnv(),
|
|
1175
|
+
}).toString('utf8');
|
|
1176
|
+
for (const d of JSON.parse(raw)) modelDump.set(d.fqcn, d);
|
|
1177
|
+
// A DECLARED relation names its related model (`$post->comments` is a
|
|
1178
|
+
// Comment), and the compiled read needs that class to exist at the
|
|
1179
|
+
// edge. The related models are not in any controller body, so they
|
|
1180
|
+
// are pulled in here - transitively, until nothing new arrives.
|
|
1181
|
+
const dumpMore = (fqcns) => {
|
|
1182
|
+
if (!fqcns.length) return;
|
|
1183
|
+
const more = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...fqcns], {
|
|
1184
|
+
cwd: appDir,
|
|
1185
|
+
env: childEnv(),
|
|
1186
|
+
}).toString('utf8');
|
|
1187
|
+
for (const d of JSON.parse(more)) modelDump.set(d.fqcn, d);
|
|
1188
|
+
};
|
|
1189
|
+
const seenRel = new Set();
|
|
1190
|
+
for (let round = 0; round < 4; round++) {
|
|
1191
|
+
const pending = [];
|
|
1192
|
+
for (const d of modelDump.values()) {
|
|
1193
|
+
for (const rel of Array.isArray(d.relations) ? d.relations : []) {
|
|
1194
|
+
if (seenRel.has(rel.related) || modelDump.has(rel.related)) continue;
|
|
1195
|
+
seenRel.add(rel.related);
|
|
1196
|
+
pending.push(rel.related);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (!pending.length) break;
|
|
1200
|
+
dumpMore(pending);
|
|
1201
|
+
}
|
|
1202
|
+
// The related models are compiled too (the emit loop walks
|
|
1203
|
+
// `aliasToFqcns`), and their short name is the alias.
|
|
1204
|
+
for (const fqcn of modelDump.keys()) {
|
|
1205
|
+
const short = fqcn.split('\\').pop();
|
|
1206
|
+
if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
|
|
1207
|
+
aliasToFqcns.get(short).add(fqcn);
|
|
1208
|
+
if (!allFqcns.includes(fqcn)) allFqcns.push(fqcn);
|
|
1209
|
+
}
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
console.log(
|
|
1212
|
+
` model dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
|
|
1213
|
+
.trim()
|
|
1214
|
+
.slice(0, 200)}`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// The Gate/policy metadata: which policy answers for a model, which
|
|
1220
|
+
// abilities it has, whether a guest may call them, and whether the app
|
|
1221
|
+
// registers global Gate callbacks (before/after/define).
|
|
1222
|
+
let policyDump = { globals: { before: 0, after: 0, abilities: [] }, models: [] };
|
|
1223
|
+
if (allFqcns.length) {
|
|
1224
|
+
console.log(`• dumping the Gate policies (${allFqcns.length})`);
|
|
1225
|
+
try {
|
|
1226
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'policy-dump.php'), ...allFqcns], {
|
|
1227
|
+
cwd: appDir,
|
|
1228
|
+
env: childEnv(),
|
|
1229
|
+
}).toString('utf8');
|
|
1230
|
+
policyDump = JSON.parse(raw);
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
console.log(
|
|
1233
|
+
` policy dump failed — the routes that check the Gate stay blocked: ${String(e.stderr || e.message || '')
|
|
1234
|
+
.trim()
|
|
1235
|
+
.slice(0, 200)}`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// The event listeners (the classes): at the edge there is no listener
|
|
1241
|
+
// runtime, so the build compiles the listeners (and the event classes).
|
|
1242
|
+
let classesDump = { events: {}, classes: {} };
|
|
1243
|
+
try {
|
|
1244
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'event-dump.php'), appDir], {
|
|
1245
|
+
cwd: appDir,
|
|
1246
|
+
env: childEnv(),
|
|
1247
|
+
}).toString('utf8');
|
|
1248
|
+
classesDump = JSON.parse(raw);
|
|
1249
|
+
} catch (e) {
|
|
1250
|
+
console.log(
|
|
1251
|
+
` event dump failed — the routes that dispatch an event stay blocked: ${String(e.stderr || e.message || '')
|
|
1252
|
+
.trim()
|
|
1253
|
+
.slice(0, 200)}`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// The GLOBAL Gate abilities' callback classes (`Gate::define('x',
|
|
1258
|
+
// 'App\\Policies\\Y@method')`) are app classes too: the kernel's gate
|
|
1259
|
+
// instantiates them, so they join the compiled set.
|
|
1260
|
+
const gateCallbackClasses = Object.values(
|
|
1261
|
+
(policyDump.globals && policyDump.globals.definitions) || {}
|
|
1262
|
+
)
|
|
1263
|
+
.filter((d) => d && d.kind === 'string' && d.callback)
|
|
1264
|
+
.map((d) => String(d.callback).split('@')[0]);
|
|
1265
|
+
if (gateCallbackClasses.length) {
|
|
1266
|
+
const seen = new Set(Object.keys(classesDump.classes || {}));
|
|
1267
|
+
for (const fqcn of gateCallbackClasses) {
|
|
1268
|
+
if (!seen.has(fqcn)) {
|
|
1269
|
+
try {
|
|
1270
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'class-dump.php'), fqcn], {
|
|
1271
|
+
cwd: appDir,
|
|
1272
|
+
env: childEnv(),
|
|
1273
|
+
}).toString('utf8');
|
|
1274
|
+
const extra = JSON.parse(raw);
|
|
1275
|
+
for (const [k, v] of Object.entries(extra.classes || {})) classesDump.classes[k] = v;
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
console.log(
|
|
1278
|
+
` the Gate callback class ${fqcn} was not dumped — its routes stay blocked: ${String(
|
|
1279
|
+
e.stderr || e.message || ''
|
|
1280
|
+
)
|
|
1281
|
+
.trim()
|
|
1282
|
+
.slice(0, 200)}`
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// The models the policies mention (the user, the subject) need their
|
|
1290
|
+
// metadata too: a relation read inside a policy must fail loud.
|
|
1291
|
+
const policyModelFqcns = new Set();
|
|
1292
|
+
for (const pm of policyDump.models || []) {
|
|
1293
|
+
const paramLists = Object.values(pm.methods || {}).map((x) => x.params || []);
|
|
1294
|
+
if (pm.before) paramLists.push(pm.before.params || []);
|
|
1295
|
+
for (const params of paramLists) {
|
|
1296
|
+
for (const p of params) {
|
|
1297
|
+
if (p.type && !p.builtin && !policyModelFqcns.has(p.type)) policyModelFqcns.add(p.type);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const extraModelFqcns = [...policyModelFqcns].filter((fqcn) => !modelDump.has(fqcn));
|
|
1302
|
+
if (extraModelFqcns.length) {
|
|
1303
|
+
console.log(`• dumping the policy models (${extraModelFqcns.length}): ${extraModelFqcns.join(', ')}`);
|
|
1304
|
+
try {
|
|
1305
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'model-dump.php'), ...extraModelFqcns], {
|
|
1306
|
+
cwd: appDir,
|
|
1307
|
+
env: childEnv(),
|
|
1308
|
+
}).toString('utf8');
|
|
1309
|
+
for (const d of JSON.parse(raw)) modelDump.set(d.fqcn, d);
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
console.log(
|
|
1312
|
+
` policy model dump failed — the policies that use those models stay blocked: ${String(e.stderr || e.message || '')
|
|
1313
|
+
.trim()
|
|
1314
|
+
.slice(0, 200)}`
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
// The policy models join the generated registry (a policy may read them).
|
|
1319
|
+
for (const fqcn of policyModelFqcns) {
|
|
1320
|
+
const short = fqcn.split('\\').pop();
|
|
1321
|
+
if (!aliasToFqcns.has(short)) aliasToFqcns.set(short, new Set());
|
|
1322
|
+
aliasToFqcns.get(short).add(fqcn);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// FormRequest rules (rules/messages/attributes) — dumped at build time.
|
|
1326
|
+
const formRequestDump = new Map();
|
|
1327
|
+
if (formRequestFqcns.size) {
|
|
1328
|
+
const list = [...formRequestFqcns];
|
|
1329
|
+
console.log(`• dumping FormRequest rules (${list.length}): ${list.join(', ')}`);
|
|
1330
|
+
try {
|
|
1331
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'formrequest-dump.php'), ...list], {
|
|
1332
|
+
cwd: appDir,
|
|
1333
|
+
env: childEnv(),
|
|
1334
|
+
}).toString('utf8');
|
|
1335
|
+
for (const d of JSON.parse(raw)) formRequestDump.set(d.fqcn, d);
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
console.log(
|
|
1338
|
+
` form request dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
|
|
1339
|
+
.trim()
|
|
1340
|
+
.slice(0, 200)}`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// A policy (and a compiled class) may read a class constant too
|
|
1346
|
+
// (`Post::PUBLISHED`): its model and policy classes join the dump, so
|
|
1347
|
+
// the body compiles instead of stopping on an unavailable constant.
|
|
1348
|
+
// This runs AFTER both dumps (they declare their own maps below the
|
|
1349
|
+
// controller loop, so reading them earlier is a TDZ error).
|
|
1350
|
+
for (const e of policyDump.models || []) {
|
|
1351
|
+
if (e && e.model) constantFqcns.add(e.model);
|
|
1352
|
+
if (e && e.policy) constantFqcns.add(e.policy);
|
|
1353
|
+
}
|
|
1354
|
+
for (const fqcn of Object.keys(classesDump.classes || {})) constantFqcns.add(fqcn);
|
|
1355
|
+
|
|
1356
|
+
// Class constants — dumped at build time and baked into the handler.
|
|
1357
|
+
const constantDump = new Map();
|
|
1358
|
+
if (constantFqcns.size) {
|
|
1359
|
+
const list = [...constantFqcns];
|
|
1360
|
+
console.log(`• dumping class constants (${list.length}): ${list.join(', ')}`);
|
|
1361
|
+
try {
|
|
1362
|
+
const raw = execFileSync(PHP, [join(pkgDir, 'tools', 'constants-dump.php'), ...list], {
|
|
1363
|
+
cwd: appDir,
|
|
1364
|
+
env: childEnv(),
|
|
1365
|
+
}).toString('utf8');
|
|
1366
|
+
for (const d of JSON.parse(raw)) constantDump.set(d.fqcn, d);
|
|
1367
|
+
} catch (e) {
|
|
1368
|
+
console.log(
|
|
1369
|
+
` constants dump failed — controller routes stay blocked: ${String(e.stderr || e.message || '')
|
|
1370
|
+
.trim()
|
|
1371
|
+
.slice(0, 200)}`
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// views used by prepared controllers (compiled once; skipped views block their routes)
|
|
1377
|
+
// A view can depend on other views (a layout: `@extends('layouts.app')`, a
|
|
1378
|
+
// partial: `@include('partials.x')`) — the compiled PHP names them, so
|
|
1379
|
+
// the build compiles the whole closure and the handler imports it.
|
|
1380
|
+
const neededViews = new Set();
|
|
1381
|
+
for (const p of prepared) for (const v of p.meta.views || []) neededViews.add(v);
|
|
1382
|
+
// The pagination view (the framework's `pagination::tailwind`): a route
|
|
1383
|
+
// with a paginator renders it (`$paginator->links()`).
|
|
1384
|
+
const paginationView = runtimeConfig['pagination.view'] || 'pagination::tailwind';
|
|
1385
|
+
const usesPagination = prepared.some((p) => p.meta.usesPaginator);
|
|
1386
|
+
if (usesPagination) neededViews.add(paginationView);
|
|
1387
|
+
// The kinds of the data the views receive (a view carries no type
|
|
1388
|
+
// information, so the build tells the compiler).
|
|
1389
|
+
const viewDataKinds = {};
|
|
1390
|
+
for (const p of prepared) {
|
|
1391
|
+
for (const [v, kinds] of Object.entries(p.meta.viewDataKinds || {})) {
|
|
1392
|
+
viewDataKinds[v] = { ...(viewDataKinds[v] || {}), ...kinds };
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (usesPagination) {
|
|
1396
|
+
viewDataKinds[paginationView] = {
|
|
1397
|
+
...(viewDataKinds[paginationView] || {}),
|
|
1398
|
+
paginator: 'paginator',
|
|
1399
|
+
elements: 'array',
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const compiledViews = new Set();
|
|
1403
|
+
const failedViews = new Set();
|
|
1404
|
+
const viewDeps = new Map();
|
|
1405
|
+
const scanViewDeps = (view, compiledPhp) => {
|
|
1406
|
+
const deps = [];
|
|
1407
|
+
const re = /make\(\s*'([^']+)'/g;
|
|
1408
|
+
let m;
|
|
1409
|
+
while ((m = re.exec(String(compiledPhp || '')))) {
|
|
1410
|
+
if (m[1] && !deps.includes(m[1])) deps.push(m[1]);
|
|
1411
|
+
}
|
|
1412
|
+
viewDeps.set(view, deps);
|
|
1413
|
+
return deps;
|
|
1414
|
+
};
|
|
1415
|
+
// The mailable's views (the dump carries them): a mailable's view is not
|
|
1416
|
+
// reached from a route, so the build compiles it here.
|
|
1417
|
+
const mailViews = Array.isArray(classesDump.mailViews) ? classesDump.mailViews : [];
|
|
1418
|
+
// The views PER mailable: only the route that sends a mailable needs
|
|
1419
|
+
// them (the flat list above is the compile queue).
|
|
1420
|
+
const mailViewsByClass =
|
|
1421
|
+
classesDump.mailViewsByClass && typeof classesDump.mailViewsByClass === 'object'
|
|
1422
|
+
? classesDump.mailViewsByClass
|
|
1423
|
+
: {};
|
|
1424
|
+
const viewQueue = [...neededViews, ...mailViews];
|
|
1425
|
+
while (viewQueue.length) {
|
|
1426
|
+
const view = viewQueue.shift();
|
|
1427
|
+
if (compiledViews.has(view) || failedViews.has(view)) continue;
|
|
1428
|
+
const viewModulePath = join(dist, 'src', 'views', viewFileName(view) + '.mjs');
|
|
1429
|
+
const dumpPath = join(cacheDir, viewFileName(view) + '.compiled.json');
|
|
1430
|
+
if (existsSync(viewModulePath) && existsSync(dumpPath)) {
|
|
1431
|
+
// Already compiled (another route used it): read the dump for the
|
|
1432
|
+
// views it depends on.
|
|
1433
|
+
try {
|
|
1434
|
+
const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
|
|
1435
|
+
compiledViews.add(view);
|
|
1436
|
+
for (const d of scanViewDeps(view, dump.compiled)) {
|
|
1437
|
+
if (!compiledViews.has(d) && !failedViews.has(d)) viewQueue.push(d);
|
|
1438
|
+
}
|
|
1439
|
+
} catch {
|
|
1440
|
+
failedViews.add(view);
|
|
1441
|
+
}
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
console.log(`compiling view ${view} (controller)`);
|
|
1445
|
+
try {
|
|
1446
|
+
execFileSync(PHP, [join(pkgDir, 'tools', 'compile-view.php'), view, dumpPath, '/'], {
|
|
1447
|
+
cwd: appDir,
|
|
1448
|
+
env: childEnv(),
|
|
1449
|
+
});
|
|
1450
|
+
const dump = JSON.parse(readFileSync(dumpPath, 'utf8'));
|
|
1451
|
+
if (!dump || typeof dump.compiled !== 'string') throw new Error('compile-view produced no compiled PHP');
|
|
1452
|
+
// The kinds of the view data (the pagination view receives a
|
|
1453
|
+
// paginator): the transpiler needs them to allow the method calls.
|
|
1454
|
+
if (viewDataKinds[view]) {
|
|
1455
|
+
dump.viewDataKinds = viewDataKinds[view];
|
|
1456
|
+
writeFileSync(dumpPath, JSON.stringify(dump));
|
|
1457
|
+
}
|
|
1458
|
+
execFileSync('node', [join(pkgDir, 'compiler', 'bin', 'transpile.mjs'), dumpPath, '--json', '--out', viewModulePath], {
|
|
1459
|
+
cwd: appDir,
|
|
1460
|
+
});
|
|
1461
|
+
writeFileSync(join(dist, 'src', 'contexts', viewFileName(view) + '.mjs'), 'export default ' + JSON.stringify(dump.context) + ';\n');
|
|
1462
|
+
compiledViews.add(view);
|
|
1463
|
+
for (const d of scanViewDeps(view, dump.compiled)) {
|
|
1464
|
+
if (!compiledViews.has(d) && !failedViews.has(d)) viewQueue.push(d);
|
|
1465
|
+
}
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
failedViews.add(view);
|
|
1468
|
+
console.log(` view ${view}: skipped — ${String(e.stderr || e.message || '').trim().slice(0, 300)}`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
// The views a route needs: the ones it renders + the closure of their
|
|
1472
|
+
// dependencies (the layout).
|
|
1473
|
+
const viewsFor = (views) => {
|
|
1474
|
+
const out = [];
|
|
1475
|
+
const seen = new Set();
|
|
1476
|
+
const add = (v) => {
|
|
1477
|
+
if (seen.has(v)) return;
|
|
1478
|
+
seen.add(v);
|
|
1479
|
+
out.push(v);
|
|
1480
|
+
for (const d of viewDeps.get(v) || []) add(d);
|
|
1481
|
+
};
|
|
1482
|
+
for (const v of views || []) add(v);
|
|
1483
|
+
return out;
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
// generated model classes + route-name -> uri map (imported by controller handlers)
|
|
1487
|
+
const classVarNames = new Map(); // fqcn -> generated class identifier
|
|
1488
|
+
const modelLines = ['// generated by laravel-workers — do not edit', "import { Model } from '../kernel/model.mjs';", ''];
|
|
1489
|
+
// The relation lines wait until every model class is DECLARED (a class
|
|
1490
|
+
// binding is not hoisted, so `static relations` may only name one after
|
|
1491
|
+
// the declaration ran).
|
|
1492
|
+
const relationLines = [];
|
|
1493
|
+
// The compiled accessors + the ones that did not compile (the build
|
|
1494
|
+
// reports them and the routes that read them stay blocked).
|
|
1495
|
+
const accessorLines = [];
|
|
1496
|
+
const accessorErrors = [];
|
|
1497
|
+
const accessorWork = [];
|
|
1498
|
+
// fqcn -> the accessor attribute names the edge now CARRIES (compiled):
|
|
1499
|
+
// they leave `notCompiled`, so a property read of one compiles.
|
|
1500
|
+
const compiledAccessors = new Map();
|
|
1501
|
+
// varName/fqcn/names: `.notCompiled` waits for the accessor pass.
|
|
1502
|
+
const notCompiledWork = [];
|
|
1503
|
+
for (const [, fqcns] of aliasToFqcns) {
|
|
1504
|
+
for (const fqcn of fqcns) {
|
|
1505
|
+
if (classVarNames.has(fqcn)) continue;
|
|
1506
|
+
const d = modelDump.get(fqcn);
|
|
1507
|
+
if (!d || !d.model) continue;
|
|
1508
|
+
const varName = 'M_' + fqcn.replace(/[^A-Za-z0-9]/g, '_');
|
|
1509
|
+
classVarNames.set(fqcn, varName);
|
|
1510
|
+
modelLines.push(`export class ${varName} extends Model {}`);
|
|
1511
|
+
modelLines.push(`${varName}.table = ${JSON.stringify(d.table)};`);
|
|
1512
|
+
modelLines.push(`${varName}.primaryKey = ${JSON.stringify(d.primaryKey || 'id')};`);
|
|
1513
|
+
const casts = d.casts || {};
|
|
1514
|
+
modelLines.push(`${varName}.fillable = ${JSON.stringify(d.fillable || [])};`);
|
|
1515
|
+
modelLines.push(`${varName}.casts = ${JSON.stringify(casts)};`);
|
|
1516
|
+
// `hashed` casts hash at set-time like Eloquent's
|
|
1517
|
+
// castAttributeAsHashedString(), using the app's configured bcrypt
|
|
1518
|
+
// rounds (Hash::make() parity). Only emitted when the cast is present.
|
|
1519
|
+
if (Object.values(casts).some((c) => String(c).toLowerCase() === 'hashed')) {
|
|
1520
|
+
const rounds = Number(runtimeConfig['hashing.bcrypt.rounds'] ?? 12);
|
|
1521
|
+
modelLines.push(`${varName}.hashRounds = ${Number.isFinite(rounds) ? rounds : 12};`);
|
|
1522
|
+
}
|
|
1523
|
+
modelLines.push(`${varName}.timestamps = ${d.timestamps === false ? 'false' : 'true'};`);
|
|
1524
|
+
modelLines.push(`${varName}.softDeletes = ${d.softDeletes ? 'true' : 'false'};`);
|
|
1525
|
+
modelLines.push(`${varName}.hidden = ${JSON.stringify(d.hidden || [])};`);
|
|
1526
|
+
modelLines.push(`${varName}.appends = ${JSON.stringify(d.appends || [])};`);
|
|
1527
|
+
// Emitted after the accessor pass: an accessor the edge COMPILES leaves the
|
|
1528
|
+
// list, so the kernel does not refuse the read at request time.
|
|
1529
|
+
notCompiledWork.push([varName, fqcn, d.notCompiled || []]);
|
|
1530
|
+
// The model's ACCESSORS (`getFullNameAttribute`): compiled in the
|
|
1531
|
+
// second pass, once every model class is declared.
|
|
1532
|
+
for (const acc of Array.isArray(d.accessors) ? d.accessors : []) {
|
|
1533
|
+
accessorWork.push([varName, fqcn, acc]);
|
|
1534
|
+
}
|
|
1535
|
+
// The declared relations (dumped from the model's own return types):
|
|
1536
|
+
// `static relations = { comments: { type, model, foreignKey, ... } }`
|
|
1537
|
+
// - the kernel loads one lazily when it is read as a property.
|
|
1538
|
+
for (const rel of Array.isArray(d.relations) ? d.relations : []) {
|
|
1539
|
+
relationLines.push([varName, rel]);
|
|
1540
|
+
}
|
|
1541
|
+
modelLines.push('');
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
// The relations, now that every model class exists. A relation whose
|
|
1545
|
+
// related model is not compiled is dropped: the read must fail the build
|
|
1546
|
+
// (it stays in `notCompiled`), never answer a half-loaded relation.
|
|
1547
|
+
for (const [varName, rel] of relationLines) {
|
|
1548
|
+
const relatedVar = classVarNames.get(rel.related);
|
|
1549
|
+
if (!relatedVar) continue;
|
|
1550
|
+
modelLines.push(
|
|
1551
|
+
`${varName}.relations = Object.assign({}, ${varName}.relations, { ${JSON.stringify(
|
|
1552
|
+
rel.name
|
|
1553
|
+
)}: { type: ${JSON.stringify(rel.type)}, model: ${relatedVar}, foreignKey: ${JSON.stringify(
|
|
1554
|
+
rel.foreignKey
|
|
1555
|
+
)}${rel.type === 'belongsTo' ? `, ownerKey: ${JSON.stringify(rel.ownerKey)}` : `, localKey: ${JSON.stringify(rel.localKey)}`} } });`
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
// The model surface the accessor bodies compile against (the same maps
|
|
1559
|
+
// the controller path uses - built here because the model file is written
|
|
1560
|
+
// before those are declared).
|
|
1561
|
+
const accModelFqcns = new Set();
|
|
1562
|
+
const accNotCompiled = new Map();
|
|
1563
|
+
const accRelations = new Map();
|
|
1564
|
+
const accClassFqcns = {};
|
|
1565
|
+
for (const [alias, fqcns] of aliasToFqcns) {
|
|
1566
|
+
for (const fqcn of fqcns) {
|
|
1567
|
+
const d = modelDump.get(fqcn);
|
|
1568
|
+
if (!d || !d.model) continue;
|
|
1569
|
+
accModelFqcns.add(fqcn);
|
|
1570
|
+
accClassFqcns[alias] = fqcn;
|
|
1571
|
+
accClassFqcns[fqcn] = fqcn;
|
|
1572
|
+
if (Array.isArray(d.notCompiled) && d.notCompiled.length) {
|
|
1573
|
+
accNotCompiled.set(alias, d.notCompiled);
|
|
1574
|
+
accNotCompiled.set(fqcn, d.notCompiled);
|
|
1575
|
+
}
|
|
1576
|
+
if (Array.isArray(d.relations) && d.relations.length) {
|
|
1577
|
+
const byName = {};
|
|
1578
|
+
for (const rel of d.relations) {
|
|
1579
|
+
byName[rel.name] = { type: rel.type, related: rel.related, foreignKey: rel.foreignKey };
|
|
1580
|
+
}
|
|
1581
|
+
accRelations.set(alias, byName);
|
|
1582
|
+
accRelations.set(fqcn, byName);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
for (const [varName, fqcn, acc] of accessorWork) {
|
|
1587
|
+
const compiledAcc = compileOneMethod(fqcn, acc.file, acc.method, {
|
|
1588
|
+
models: accModelFqcns,
|
|
1589
|
+
modelNotCompiled: accNotCompiled,
|
|
1590
|
+
modelClassFqcns: accClassFqcns,
|
|
1591
|
+
modelRelations: accRelations,
|
|
1592
|
+
// The class constants are dumped later in the build, so an accessor
|
|
1593
|
+
// that reads one fails loud (a precise reason) instead of guessing.
|
|
1594
|
+
constants: {},
|
|
1595
|
+
classes: new Set(Object.keys(classesDump.classes || {})),
|
|
1596
|
+
});
|
|
1597
|
+
if (compiledAcc.error) {
|
|
1598
|
+
accessorErrors.push(`${fqcn}::${acc.method}() — ${compiledAcc.error}`);
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
modelLines.push(`${varName}.prototype[${JSON.stringify(acc.method)}] = ${compiledAcc.js};`);
|
|
1602
|
+
const names = compiledAccessors.get(fqcn) || [];
|
|
1603
|
+
names.push(acc.name);
|
|
1604
|
+
compiledAccessors.set(fqcn, names);
|
|
1605
|
+
}
|
|
1606
|
+
for (const e of accessorErrors) console.log(` ! accessor ${e}`);
|
|
1607
|
+
for (const [varName, fqcn, names] of notCompiledWork) {
|
|
1608
|
+
const stillRefused = names.filter((n) => !(compiledAccessors.get(fqcn) || []).includes(n));
|
|
1609
|
+
modelLines.push(`${varName}.notCompiled = ${JSON.stringify(stillRefused)};`);
|
|
1610
|
+
}
|
|
1611
|
+
modelLines.push('');
|
|
1612
|
+
const mapPairs = [];
|
|
1613
|
+
for (const [alias, fqcns] of aliasToFqcns) {
|
|
1614
|
+
if (fqcns.size !== 1) continue; // ambiguous alias: never guessed
|
|
1615
|
+
const fqcn = [...fqcns][0];
|
|
1616
|
+
if (!classVarNames.has(fqcn)) continue;
|
|
1617
|
+
mapPairs.push(`${JSON.stringify(alias)}: ${classVarNames.get(fqcn)}`);
|
|
1618
|
+
mapPairs.push(`${JSON.stringify(fqcn)}: ${classVarNames.get(fqcn)}`);
|
|
1619
|
+
}
|
|
1620
|
+
modelLines.push('export const models = { ' + mapPairs.join(', ') + ' };');
|
|
1621
|
+
const routeMapObj = {};
|
|
1622
|
+
for (const route of routes) {
|
|
1623
|
+
const { name } = metaForRoute(route);
|
|
1624
|
+
if (name && !(name in routeMapObj)) routeMapObj[name] = route.replace(/^\//, '');
|
|
1625
|
+
}
|
|
1626
|
+
modelLines.push('export const routeMap = ' + JSON.stringify(routeMapObj) + ';');
|
|
1627
|
+
modelLines.push('');
|
|
1628
|
+
writeFileSync(join(dist, 'src', 'models.generated.mjs'), modelLines.join('\n'));
|
|
1629
|
+
|
|
1630
|
+
// The compiled policies (the Gate checks). The policies become plain
|
|
1631
|
+
// async functions; the guest rules and the before() hook are baked in.
|
|
1632
|
+
const knownModelFqcnSet = new Set();
|
|
1633
|
+
const modelNotCompiled = new Map();
|
|
1634
|
+
const modelClassFqcns = {};
|
|
1635
|
+
for (const [alias, fqcns] of aliasToFqcns) {
|
|
1636
|
+
for (const fqcn of fqcns) {
|
|
1637
|
+
const d = modelDump.get(fqcn);
|
|
1638
|
+
if (!d || !d.model) continue;
|
|
1639
|
+
knownModelFqcnSet.add(fqcn);
|
|
1640
|
+
modelClassFqcns[alias] = fqcn;
|
|
1641
|
+
modelClassFqcns[fqcn] = fqcn;
|
|
1642
|
+
if (Array.isArray(d.notCompiled) && d.notCompiled.length) {
|
|
1643
|
+
modelNotCompiled.set(alias, d.notCompiled);
|
|
1644
|
+
modelNotCompiled.set(fqcn, d.notCompiled);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
// The dumped constants keyed by the name the source writes (the alias
|
|
1649
|
+
// and the FQCN). An alias shared by two classes is skipped: the
|
|
1650
|
+
// transpiler must not guess which one the source meant.
|
|
1651
|
+
const constantsByAlias = {};
|
|
1652
|
+
const aliasOwners = new Map();
|
|
1653
|
+
for (const [fqcn, dumped] of constantDump) {
|
|
1654
|
+
if (!dumped || dumped.error) continue;
|
|
1655
|
+
const alias = fqcn.split('\\').pop();
|
|
1656
|
+
if (aliasOwners.has(alias) && aliasOwners.get(alias) !== fqcn) {
|
|
1657
|
+
aliasOwners.set(alias, null);
|
|
1658
|
+
} else {
|
|
1659
|
+
aliasOwners.set(alias, fqcn);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
for (const [fqcn, dumped] of constantDump) {
|
|
1663
|
+
if (!dumped || dumped.error) continue;
|
|
1664
|
+
const alias = fqcn.split('\\').pop();
|
|
1665
|
+
if (aliasOwners.get(alias) === fqcn) constantsByAlias[alias] = dumped.constants || {};
|
|
1666
|
+
constantsByAlias[fqcn] = dumped.constants || {};
|
|
1667
|
+
}
|
|
1668
|
+
const policyCompile = compilePolicies(policyDump, {
|
|
1669
|
+
models: knownModelFqcnSet,
|
|
1670
|
+
modelNotCompiled,
|
|
1671
|
+
modelClassFqcns,
|
|
1672
|
+
// The user of a policy: the app's auth provider model.
|
|
1673
|
+
userModel: authProviderFqcns[0] || null,
|
|
1674
|
+
// The class constants the policy bodies may read.
|
|
1675
|
+
constants: constantsByAlias,
|
|
1676
|
+
});
|
|
1677
|
+
writeFileSync(join(dist, 'src', 'policies.generated.mjs'), policyCompile.code);
|
|
1678
|
+
const policiesMeta = policyCompile.meta;
|
|
1679
|
+
console.log(
|
|
1680
|
+
` -> dist/src/policies.generated.mjs (${Object.keys(policiesMeta).length} model(s)${
|
|
1681
|
+
policyCompile.errors.length ? `, ${policyCompile.errors.length} policy/policies did not compile` : ''
|
|
1682
|
+
})`
|
|
1683
|
+
);
|
|
1684
|
+
for (const e of policyCompile.errors) {
|
|
1685
|
+
console.log(` ! ${e.model}: ${e.message}`);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// The classes (the event listeners + the event classes): the compiled
|
|
1689
|
+
// constructors and methods (the edge has no listener runtime).
|
|
1690
|
+
const classesCompile = compileClasses(classesDump, {
|
|
1691
|
+
models: knownModelFqcnSet,
|
|
1692
|
+
modelNotCompiled,
|
|
1693
|
+
modelClassFqcns,
|
|
1694
|
+
// The class constants a listener/service body may read.
|
|
1695
|
+
constants: constantsByAlias,
|
|
1696
|
+
classes: new Set(Object.keys(classesDump.classes || {})),
|
|
1697
|
+
});
|
|
1698
|
+
writeFileSync(join(dist, 'src', 'classes.generated.mjs'), classesCompile.code);
|
|
1699
|
+
// The alias of each compiled class (`new PostPublished($id)`): an
|
|
1700
|
+
// ambiguous short name is not resolved (the build fails loud on it).
|
|
1701
|
+
const classAliases = new Map();
|
|
1702
|
+
for (const fqcn of Object.keys(classesCompile.meta)) {
|
|
1703
|
+
const alias = fqcn.split('\\').pop();
|
|
1704
|
+
if (classAliases.has(alias)) classAliases.delete(alias);
|
|
1705
|
+
else classAliases.set(alias, fqcn);
|
|
1706
|
+
}
|
|
1707
|
+
// The events that did not compile (the event class itself or one of its
|
|
1708
|
+
// listeners): a dispatch of one of them would be a silent no-op (the edge
|
|
1709
|
+
// has no listener runtime), so the routes that dispatch it stay blocked -
|
|
1710
|
+
// and the build names the reason (the listener is queued, the listener
|
|
1711
|
+
// injects a service, ...).
|
|
1712
|
+
const classErrors = new Map();
|
|
1713
|
+
for (const e of classesCompile.errors) classErrors.set(e.class, e.message);
|
|
1714
|
+
const blockedClasses = {};
|
|
1715
|
+
const blockedShortNames = new Set();
|
|
1716
|
+
// EVERY class that did not compile is blocked - not only the event
|
|
1717
|
+
// classes: a mailable with Laravel 13's modern API, a listener with a
|
|
1718
|
+
// queued trait, ... The transpiler then refuses to instantiate it, and
|
|
1719
|
+
// the route that instantiates it is stopped with the reason.
|
|
1720
|
+
for (const [cls, why] of classErrors) {
|
|
1721
|
+
blockedClasses[cls] = why;
|
|
1722
|
+
const short = cls.split('\\').pop();
|
|
1723
|
+
if (!blockedShortNames.has(short)) {
|
|
1724
|
+
blockedShortNames.add(short);
|
|
1725
|
+
blockedClasses[short] = why;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
for (const [event, info] of Object.entries(classesDump.events || {})) {
|
|
1729
|
+
const names = [event, ...(info.listeners || [])];
|
|
1730
|
+
const bad = names.find((n) => classErrors.has(n));
|
|
1731
|
+
if (!bad) continue;
|
|
1732
|
+
const why = classErrors.get(bad);
|
|
1733
|
+
blockedClasses[event] = why;
|
|
1734
|
+
const short = event.split('\\').pop();
|
|
1735
|
+
if (!blockedShortNames.has(short)) {
|
|
1736
|
+
blockedShortNames.add(short);
|
|
1737
|
+
blockedClasses[short] = why;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
const classesMeta = {
|
|
1741
|
+
fqcns: Object.keys(classesCompile.meta),
|
|
1742
|
+
// The JOB classes (app/Jobs): `Job::dispatch()` writes a queue row and
|
|
1743
|
+
// the cron consumer runs the class's handle(), so the transpiler must
|
|
1744
|
+
// know which classes are jobs (a plain `dispatch()` on anything else
|
|
1745
|
+
// is not a queue push).
|
|
1746
|
+
jobs: Object.keys(classesDump.classes || {}).filter(
|
|
1747
|
+
(fqcn) => (classesDump.classes[fqcn] || {}).kind === 'job'
|
|
1748
|
+
),
|
|
1749
|
+
aliases: Object.fromEntries(classAliases),
|
|
1750
|
+
blocked: blockedClasses,
|
|
1751
|
+
// The compiled classes' own methods (FQCN -> { methods, base }): a
|
|
1752
|
+
// call on an INLINE instance (`(new OrderService())->doIt($o)`) must
|
|
1753
|
+
// hand the runtime over as the first argument - the compiled methods
|
|
1754
|
+
// are `async function (rt, ...)`. Without this the app's first
|
|
1755
|
+
// argument landed in the `rt` slot (a 500 where Laravel answers 200).
|
|
1756
|
+
registry: Object.fromEntries(
|
|
1757
|
+
Object.entries(classesCompile.meta).map(([fqcn, m]) => [
|
|
1758
|
+
fqcn,
|
|
1759
|
+
{ methods: m.methods || [], base: m.base || null },
|
|
1760
|
+
])
|
|
1761
|
+
),
|
|
1762
|
+
};
|
|
1763
|
+
console.log(
|
|
1764
|
+
` -> dist/src/classes.generated.mjs (${classesMeta.fqcns.length} class(es)${
|
|
1765
|
+
classesCompile.errors.length ? `, ${classesCompile.errors.length} did not compile` : ''
|
|
1766
|
+
})`
|
|
1767
|
+
);
|
|
1768
|
+
for (const e of classesCompile.errors) {
|
|
1769
|
+
console.log(` ! ${e.code || 'LW002'} ${e.class}: ${e.message}`);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// phase C: emit one handler module per transpilable route
|
|
1773
|
+
let emittedCount = 0;
|
|
1774
|
+
for (const p of prepared) {
|
|
1775
|
+
// A class the route instantiates that did not compile (a mailable with
|
|
1776
|
+
// Laravel 13's modern API, ...) makes the route fail at REQUEST time -
|
|
1777
|
+
// the build stops the route and names the reason.
|
|
1778
|
+
const brokenClass = (p.meta.newClasses || []).find(
|
|
1779
|
+
(c) => classErrors.has(c.fqcn) || classErrors.has(c.alias)
|
|
1780
|
+
);
|
|
1781
|
+
if (brokenClass) {
|
|
1782
|
+
console.log(
|
|
1783
|
+
` controller ${p.route}: skipped — the class ${brokenClass.fqcn} did not compile: ${
|
|
1784
|
+
classErrors.get(brokenClass.fqcn) || classErrors.get(brokenClass.alias)
|
|
1785
|
+
}`
|
|
1786
|
+
);
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
const ambiguous = (p.meta.classes || []).find((c) => (aliasToFqcns.get(c.alias) || new Set()).size > 1);
|
|
1790
|
+
if (ambiguous) {
|
|
1791
|
+
console.log(` controller ${p.route}: skipped — model alias "${ambiguous.alias}" is ambiguous between namespaces`);
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
// A JOB class is not a model: `Job::dispatch()` is a queue push the
|
|
1795
|
+
// transpiler compiles, so it must not be judged by the model dump.
|
|
1796
|
+
const jobFqcns = new Set(
|
|
1797
|
+
Object.keys(classesDump.classes || {}).filter(
|
|
1798
|
+
(fqcn) => (classesDump.classes[fqcn] || {}).kind === 'job'
|
|
1799
|
+
)
|
|
1800
|
+
);
|
|
1801
|
+
// A framework class the transpiler compiles ITSELF (`Collection::make()`
|
|
1802
|
+
// / `Collection::wrap()` -> the kernel Collection) is not an app model,
|
|
1803
|
+
// so the model dump must not judge it. Without this the route is
|
|
1804
|
+
// skipped with "not a usable Eloquent model" - a misleading refusal for
|
|
1805
|
+
// a construct that compiles.
|
|
1806
|
+
const frameworkFqcns = new Set(['Collection', 'Illuminate\\Support\\Collection']);
|
|
1807
|
+
const badModel = (p.meta.classes || []).find((c) => {
|
|
1808
|
+
if (jobFqcns.has(c.fqcn)) return false;
|
|
1809
|
+
if (frameworkFqcns.has(c.fqcn)) return false;
|
|
1810
|
+
const d = modelDump.get(c.fqcn);
|
|
1811
|
+
return !d || !d.model;
|
|
1812
|
+
});
|
|
1813
|
+
if (badModel) {
|
|
1814
|
+
const d = modelDump.get(badModel.fqcn);
|
|
1815
|
+
console.log(
|
|
1816
|
+
` controller ${p.route}: skipped — ${badModel.fqcn} is not a usable Eloquent model${
|
|
1817
|
+
d && d.error ? ' (' + d.error + ')' : ''
|
|
1818
|
+
}`
|
|
1819
|
+
);
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
// FormRequest rules for this route (by FQCN). A class-typed parameter
|
|
1823
|
+
// that is not a FormRequest is a service — the route needs a port.
|
|
1824
|
+
const formRequests = {};
|
|
1825
|
+
let formRequestError = null;
|
|
1826
|
+
for (const param of p.meta.params || []) {
|
|
1827
|
+
if (param.inject === 'request') continue;
|
|
1828
|
+
if (!param.typeFqcn) continue;
|
|
1829
|
+
// A model-typed parameter is the implicit route-model binding (the
|
|
1830
|
+
// model comes from the route segment) — it is not a FormRequest.
|
|
1831
|
+
const md = modelDump.get(param.typeFqcn);
|
|
1832
|
+
if (md && md.model) continue;
|
|
1833
|
+
const dumped = formRequestDump.get(param.typeFqcn);
|
|
1834
|
+
if (!dumped || dumped.error) {
|
|
1835
|
+
// The dump knows WHY (a rule object, an overridden
|
|
1836
|
+
// prepareForValidation(), ...): the reason must survive - a generic
|
|
1837
|
+
// "unsupported type" would hide it.
|
|
1838
|
+
formRequestError =
|
|
1839
|
+
dumped && dumped.error
|
|
1840
|
+
? `$${param.name} (${param.typeHint}): ${dumped.error}`
|
|
1841
|
+
: `$${param.name} has unsupported type "${param.typeHint}" — only scalar parameters, Illuminate\\Http\\Request and FormRequest are supported here`;
|
|
1842
|
+
break;
|
|
1843
|
+
}
|
|
1844
|
+
formRequests[param.typeFqcn] = {
|
|
1845
|
+
rules: dumped.rules || {},
|
|
1846
|
+
messages: dumped.messages || {},
|
|
1847
|
+
attributes: dumped.attributes || {},
|
|
1848
|
+
// The hooks Laravel runs on the request (prepareForValidation) -
|
|
1849
|
+
// the compiled handler runs them in Laravel's order.
|
|
1850
|
+
hooks: dumped.hooks || [],
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
if (formRequestError) {
|
|
1854
|
+
console.log(` controller ${p.route}: skipped — ${formRequestError}`);
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
// Class constants for this route (by alias — the transpiler resolves the
|
|
1858
|
+
// alias as written in the source).
|
|
1859
|
+
const constants = {};
|
|
1860
|
+
let constantError = null;
|
|
1861
|
+
for (const c of p.meta.constants || []) {
|
|
1862
|
+
const dumped = constantDump.get(c.fqcn);
|
|
1863
|
+
if (!dumped || dumped.error) {
|
|
1864
|
+
constantError = `${c.fqcn} (${dumped ? dumped.error : 'not dumped'})`;
|
|
1865
|
+
break;
|
|
1866
|
+
}
|
|
1867
|
+
if (constants[c.alias]) {
|
|
1868
|
+
constantError = `${c.alias} is ambiguous between classes`;
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
constants[c.alias] = dumped.constants || {};
|
|
1872
|
+
}
|
|
1873
|
+
if (constantError) {
|
|
1874
|
+
console.log(` controller ${p.route}: skipped — class constant ${constantError}`);
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
// The views the route renders + the ones they depend on (the layout).
|
|
1878
|
+
// The route's views + the pagination view (when the route has a
|
|
1879
|
+
// paginator: `$paginator->links()` renders it).
|
|
1880
|
+
const routeViewNames = [...(p.meta.views || [])];
|
|
1881
|
+
// A mailable's view (the route sends a mail): the runtime renders it
|
|
1882
|
+
// through the ROUTE's runtime, so the view must be in THIS route's
|
|
1883
|
+
// view map. The mailables a route can send are the ones it REACHES:
|
|
1884
|
+
// the classes its body instantiates, the listeners of the events it
|
|
1885
|
+
// dispatches, and their dependencies - a route that dispatches an event
|
|
1886
|
+
// whose listener sends a mailable needs that mailable's view.
|
|
1887
|
+
// The views are attached per route: a mailable no route reaches costs
|
|
1888
|
+
// no route a view (and one broken mailable view no longer skips every
|
|
1889
|
+
// route of the app).
|
|
1890
|
+
const routeReach = reachableClasses(p.meta.newClasses, classesMeta, {
|
|
1891
|
+
classMeta: classesCompile.meta,
|
|
1892
|
+
dumpClasses: classesDump.classes,
|
|
1893
|
+
events: classesDump.events,
|
|
1894
|
+
});
|
|
1895
|
+
for (const mailable of reachedMailables(routeReach.reached, classesDump.classes)) {
|
|
1896
|
+
for (const v of mailViewsByClass[mailable] || []) routeViewNames.push(v);
|
|
1897
|
+
}
|
|
1898
|
+
if (p.meta.usesPaginator) routeViewNames.push(paginationView);
|
|
1899
|
+
const routeViews = viewsFor(routeViewNames);
|
|
1900
|
+
const missingView = routeViews.find((v) => !compiledViews.has(v));
|
|
1901
|
+
if (missingView) {
|
|
1902
|
+
// A mailable's view that did not compile: the reason names the
|
|
1903
|
+
// mailable (the class that renders it) and the route that reaches it -
|
|
1904
|
+
// the route would answer 500 where Laravel sends the mail.
|
|
1905
|
+
const mailableOf = Object.keys(mailViewsByClass).find(
|
|
1906
|
+
(m) => (mailViewsByClass[m] || []).includes(missingView)
|
|
1907
|
+
);
|
|
1908
|
+
console.log(
|
|
1909
|
+
mailableOf
|
|
1910
|
+
? ` controller ${p.route}: skipped — the view "${missingView}" of the mailable ${mailableOf} did not compile`
|
|
1911
|
+
: ` controller ${p.route}: skipped — view "${missingView}" did not compile`
|
|
1912
|
+
);
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
// The CORS paths (HandleCors): the compiled worker does not add the CORS
|
|
1916
|
+
// headers, so a route under a CORS path is skipped (a silent answer
|
|
1917
|
+
// without the headers would break the browser's CORS).
|
|
1918
|
+
const corsPath = corsPathFor(p.route);
|
|
1919
|
+
if (corsPath) {
|
|
1920
|
+
console.log(
|
|
1921
|
+
` controller ${p.route}: skipped — the app configures CORS for "${corsPath}" and the ` +
|
|
1922
|
+
'compiled worker does not add the CORS headers — remove the CORS config (config/cors.php) ' +
|
|
1923
|
+
'or answer the preflight on the platform'
|
|
1924
|
+
);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
const isExtraVerb = extraVerbsFor(p.route).includes(p.verb);
|
|
1928
|
+
// `--` keeps the extra-verb slug apart from a base route slug
|
|
1929
|
+
// (POST /posts -> "posts--post" vs GET /posts/{post} -> "posts-post").
|
|
1930
|
+
const slug = isExtraVerb
|
|
1931
|
+
? `${slugifyRoute(p.route)}--${String(p.verb).toLowerCase()}`
|
|
1932
|
+
: slugifyRoute(p.route);
|
|
1933
|
+
const meta = {
|
|
1934
|
+
routeUri: p.route,
|
|
1935
|
+
// The pagination view (the framework's `pagination::tailwind`): the
|
|
1936
|
+
// handler imports it when the route has a paginator.
|
|
1937
|
+
paginationView,
|
|
1938
|
+
// A paginator in the method + the kinds of the data the views get.
|
|
1939
|
+
usesPaginator: !!p.meta.usesPaginator,
|
|
1940
|
+
viewDataKinds: p.meta.viewDataKinds || {},
|
|
1941
|
+
routeParams: (p.route.match(/\{([^}]+)\}/g) || []).map((s) => s.slice(1, -1).replace(/[?*]$/, '')),
|
|
1942
|
+
models: (() => {
|
|
1943
|
+
// The models this route uses: the classes in the body + the
|
|
1944
|
+
// model-bound parameters (the implicit route-model binding).
|
|
1945
|
+
const list = [];
|
|
1946
|
+
const seen = new Set();
|
|
1947
|
+
const push = (alias, fqcn) => {
|
|
1948
|
+
if (!fqcn || seen.has(fqcn)) return;
|
|
1949
|
+
seen.add(fqcn);
|
|
1950
|
+
const d = modelDump.get(fqcn) || {};
|
|
1951
|
+
list.push({
|
|
1952
|
+
alias,
|
|
1953
|
+
fqcn,
|
|
1954
|
+
// The methods the app declares on the model (the relations, the
|
|
1955
|
+
// accessors): the compiler rejects them (a relation read as a
|
|
1956
|
+
// property used to answer null - a wrong answer, silently).
|
|
1957
|
+
notCompiled: (d.notCompiled || []).filter(
|
|
1958
|
+
(n) => !(compiledAccessors.get(fqcn) || []).includes(n)
|
|
1959
|
+
),
|
|
1960
|
+
// The relations the model declares: a property read of one is
|
|
1961
|
+
// COMPILED (a lazy load), not refused. A relation whose model
|
|
1962
|
+
// is not compiled is dropped here, so it stays refused.
|
|
1963
|
+
relations: (Array.isArray(d.relations) ? d.relations : []).filter((r) =>
|
|
1964
|
+
modelDump.has(r.related) && modelDump.get(r.related).model
|
|
1965
|
+
),
|
|
1966
|
+
// The route-model binding key ('id' by default) + the key.
|
|
1967
|
+
routeKey: d.routeKey || d.primaryKey || 'id',
|
|
1968
|
+
primaryKey: d.primaryKey || 'id',
|
|
1969
|
+
});
|
|
1970
|
+
};
|
|
1971
|
+
for (const c of p.meta.classes || []) {
|
|
1972
|
+
const d = modelDump.get(c.fqcn);
|
|
1973
|
+
if (!d || !d.model) continue;
|
|
1974
|
+
push(c.alias, c.fqcn);
|
|
1975
|
+
}
|
|
1976
|
+
for (const param of p.meta.params || []) {
|
|
1977
|
+
if (param.inject === 'request') continue;
|
|
1978
|
+
if (!param.typeFqcn) continue;
|
|
1979
|
+
const d = modelDump.get(param.typeFqcn);
|
|
1980
|
+
if (!d || !d.model) continue;
|
|
1981
|
+
push(param.typeFqcn.split('\\').pop(), param.typeFqcn);
|
|
1982
|
+
}
|
|
1983
|
+
return list;
|
|
1984
|
+
})(),
|
|
1985
|
+
views: routeViews,
|
|
1986
|
+
constants,
|
|
1987
|
+
formRequests,
|
|
1988
|
+
// The Gate metadata (the policies, their abilities, the guest rules).
|
|
1989
|
+
policies: policiesMeta,
|
|
1990
|
+
// The GLOBAL Gate::define() abilities: the compilable ones (a
|
|
1991
|
+
// 'Class@method' string callback) reach the kernel's gate; a closure
|
|
1992
|
+
// never gets here (the transpiler refuses the call).
|
|
1993
|
+
gateDefinitions: Object.fromEntries(
|
|
1994
|
+
Object.entries((policyDump.globals || {}).definitions || {}).filter(
|
|
1995
|
+
([, d]) => d && d.kind === 'string' && d.callback
|
|
1996
|
+
)
|
|
1997
|
+
),
|
|
1998
|
+
classes: classesMeta,
|
|
1999
|
+
// The object store: the app's default disk (the compiled rung has ONE
|
|
2000
|
+
// store — the padanan for it) + the configured disk names.
|
|
2001
|
+
storage: {
|
|
2002
|
+
defaultDisk: runtimeConfig['filesystems.default'] || null,
|
|
2003
|
+
disks: Array.isArray(runtimeConfig['filesystems.disks'])
|
|
2004
|
+
? runtimeConfig['filesystems.disks']
|
|
2005
|
+
: [],
|
|
2006
|
+
},
|
|
2007
|
+
// Hash::make() must use the app's configured bcrypt rounds.
|
|
2008
|
+
hashRounds: Number(runtimeConfig['hashing.bcrypt.rounds'] ?? 12) || 12,
|
|
2009
|
+
};
|
|
2010
|
+
const metaPath = join(metaDir, slug + '.meta.json');
|
|
2011
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
2012
|
+
const outPath = join(dist, 'src', 'controllers', slug + '.mjs');
|
|
2013
|
+
try {
|
|
2014
|
+
execFileSync(
|
|
2015
|
+
process.execPath,
|
|
2016
|
+
[
|
|
2017
|
+
join(pkgDir, 'compiler', 'bin', 'controller.mjs'),
|
|
2018
|
+
'emit',
|
|
2019
|
+
'--file', p.file,
|
|
2020
|
+
'--method', p.method,
|
|
2021
|
+
'--meta', metaPath,
|
|
2022
|
+
'--out', outPath,
|
|
2023
|
+
],
|
|
2024
|
+
{ cwd: appDir }
|
|
2025
|
+
);
|
|
2026
|
+
// A handler that queues a job writes a `lw_jobs` row the CRON
|
|
2027
|
+
// TRIGGER drains. With no trigger the job would never run, so the
|
|
2028
|
+
// build refuses it below (the route itself is fine - the deployment
|
|
2029
|
+
// is not).
|
|
2030
|
+
if (readFileSync(outPath, 'utf8').includes('rt.queuePush(')) {
|
|
2031
|
+
queuePushes.push(p.route);
|
|
2032
|
+
}
|
|
2033
|
+
if (isExtraVerb) {
|
|
2034
|
+
verbControllers.push({ route: p.route, verb: p.verb, slug, fqcn: p.fqcn, method: p.method });
|
|
2035
|
+
console.log(` controller ${p.verb} ${p.route} -> dist/src/controllers/${slug}.mjs (${p.fqcn}@${p.method})`);
|
|
2036
|
+
} else {
|
|
2037
|
+
controllerRoutes.set(p.route, { slug, fqcn: p.fqcn, method: p.method });
|
|
2038
|
+
console.log(` controller ${p.route} -> dist/src/controllers/${slug}.mjs (${p.fqcn}@${p.method})`);
|
|
2039
|
+
}
|
|
2040
|
+
emittedCount++;
|
|
2041
|
+
} catch (e) {
|
|
2042
|
+
const detail = formatControllerError(e);
|
|
2043
|
+
console.log(` controller ${p.route}: skipped — ${detail}`);
|
|
2044
|
+
// The upload: the operator needs to know that the edge has no
|
|
2045
|
+
// multipart parser and what to do about it (an object store — R2
|
|
2046
|
+
// asks for a credit card; the free path is the D1 blob store — or
|
|
2047
|
+
// remove/disable the upload).
|
|
2048
|
+
if (/an uploaded file/.test(detail)) {
|
|
2049
|
+
console.log(
|
|
2050
|
+
' ! this route uploads a file: the edge has no multipart parser. Bind R2\n' +
|
|
2051
|
+
' ("r2_buckets": [{"binding":"R2","bucket_name":"<bucket>"}] in wrangler.jsonc - R2 asks for a\n' +
|
|
2052
|
+
' credit card; the free path is the D1 blob store, 1.5 MB per object) or remove/disable\n' +
|
|
2053
|
+
' the upload in this route.'
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
if (emittedCount) {
|
|
2059
|
+
console.log(`• controllers transpiled: ${emittedCount}/${targets.length}`);
|
|
2060
|
+
for (let i = needsPort.length - 1; i >= 0; i--) {
|
|
2061
|
+
if (controllerRoutes.has(needsPort[i].route)) needsPort.splice(i, 1);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// A route a COMPILED controller serves is NOT a snapshot: the build-time
|
|
2066
|
+
// page is the fallback for a route the build could not compile. Keeping
|
|
2067
|
+
// both hid the controller (the shadow compared a page baked at build
|
|
2068
|
+
// time and called it parity - a stale answer for anything that depends
|
|
2069
|
+
// on the request: a query string, a row created during the run).
|
|
2070
|
+
for (const route of [...snapshots]) {
|
|
2071
|
+
if (!controllerRoutes.has(route)) continue;
|
|
2072
|
+
snapshots.splice(snapshots.indexOf(route), 1);
|
|
2073
|
+
const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
|
|
2074
|
+
try {
|
|
2075
|
+
rmSync(join(dist, 'public', file), { force: true });
|
|
2076
|
+
} catch {
|
|
2077
|
+
/* the page was never written */
|
|
2078
|
+
}
|
|
2079
|
+
console.log(` snapshot ${route} dropped - the compiled controller serves it`);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// The Storage facade needs an object store: the app's local disk is
|
|
2083
|
+
// object storage at the edge (there is no local filesystem). R2 when the
|
|
2084
|
+
// app binds it, else the D1 blob store (one row per object - no extra
|
|
2085
|
+
// Cloudflare product, no card). With neither, the compiled handler would
|
|
2086
|
+
// only fail at request time - catch it here, where the build can still
|
|
2087
|
+
// tell the operator what to add.
|
|
2088
|
+
if (emittedCount) {
|
|
2089
|
+
const controllerDir = join(dist, 'src', 'controllers');
|
|
2090
|
+
const controllerSources = readdirSync(controllerDir)
|
|
2091
|
+
.filter((f) => f.endsWith('.mjs'))
|
|
2092
|
+
.map((f) => readFileSync(join(controllerDir, f), 'utf8'));
|
|
2093
|
+
usesStorage = controllerSources.some((s) => s.includes('rt.storage()'));
|
|
2094
|
+
if (usesStorage) {
|
|
2095
|
+
if (hasBinding(appDir, 'r2_buckets', 'R2')) {
|
|
2096
|
+
console.log('• Storage:: -> R2 (the R2 binding)');
|
|
2097
|
+
} else if (hasBinding(appDir, 'd1_databases', 'DB')) {
|
|
2098
|
+
console.log('• Storage:: -> the D1 blob store (no R2 binding): one row per object in lw_blobs.');
|
|
2099
|
+
console.log(' Free-plan limits apply: 100k row writes/day, 5M row reads/day, 500 MB per database, 1.5 MB per object.');
|
|
2100
|
+
console.log(' Bind an R2 bucket ("r2_buckets": [{"binding":"R2",...}]) for real object storage (it asks for a credit card).');
|
|
2101
|
+
} else {
|
|
2102
|
+
note({ route: null, area: 'runtime', severity: DECLARED, cause: 'a compiled controller uses the Storage facade, but the app binds neither R2 nor D1 — at the edge there is no local filesystem, so Storage::put/get/exists/delete have no object store to back them', fix: 'add the D1 binding (the app ships a D1 blob store, no extra product) or an R2 bucket to wrangler.toml. Until then, Storage facade calls return null at request time.', paths: [{ tier: 'free', what: 'add the D1 binding to wrangler.toml ({"d1_databases":[{"binding":"DB",...}]}) — the app ships a D1 blob store (one row per object in lw_blobs), no extra Cloudflare product' }, { tier: 'paid', what: 'or bind an R2 bucket ({"r2_buckets":[{"binding":"R2",...}]}) — real object storage, asks for a credit card' }] });
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
// The compiled cache store is the D1-backed `database` store (the
|
|
2106
|
+
// Laravel DatabaseCacheStore, oracle-verified). A compiled route that
|
|
2107
|
+
// uses the cache on another driver (redis, memcached, file, ...) would
|
|
2108
|
+
// read and write a DIFFERENT store - a silent divergence. The build
|
|
2109
|
+
// stops instead.
|
|
2110
|
+
const usesCache = controllerSources.some((s) => s.includes('rt.cache()'));
|
|
2111
|
+
if (usesCache && runtimeConfig['cache.driver'] !== 'database') {
|
|
2112
|
+
note({ route: null, area: 'config', severity: DECLARED, cause: `a compiled controller uses the cache, but cache.driver is '${runtimeConfig['cache.driver']}' — the compiled rung has exactly one cache store: the D1-backed 'database' store`, fix: 'set CACHE_STORE=database (with the cache + cache_locks tables) or write a port for the route. Until then, cache reads/writes hit a different store than the compiled rung expects — a silent divergence.', paths: [{ tier: 'free', what: 'set CACHE_STORE=database and migrate the cache + cache_locks tables — the compiled rung then reads/writes the D1-backed store' }, { tier: 'free', what: 'or write a port for the route (the port handles the cache)' }] });
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// Extra verbs that compiled to their own handler no longer need a port; the rest
|
|
2120
|
+
// stay as partials so `--strict` still fails on them.
|
|
2121
|
+
{
|
|
2122
|
+
const resolved = new Set(verbControllers.map((v) => v.route + ' ' + v.verb));
|
|
2123
|
+
const remaining = [];
|
|
2124
|
+
for (const p of partials) {
|
|
2125
|
+
const left = p.methods.filter((m) => !resolved.has(p.route + ' ' + m));
|
|
2126
|
+
if (left.length) remaining.push({ route: p.route, methods: left });
|
|
2127
|
+
}
|
|
2128
|
+
partials.length = 0;
|
|
2129
|
+
partials.push(...remaining);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Always emit the registries the worker template imports (models, policies,
|
|
2133
|
+
// classes) - even when NO controller compiled and no dump ran. The template is
|
|
2134
|
+
// copied into dist unconditionally, so a missing registry is a bundler error
|
|
2135
|
+
// ("Could not resolve ./classes.generated.mjs") instead of the build naming the
|
|
2136
|
+
// routes it skipped. A real registry (written earlier) wins.
|
|
2137
|
+
if (!existsSync(join(dist, 'src', 'models.generated.mjs'))) {
|
|
2138
|
+
writeFileSync(
|
|
2139
|
+
join(dist, 'src', 'models.generated.mjs'),
|
|
2140
|
+
'// generated by laravel-workers — do not edit\nimport { Model } from \'../kernel/model.mjs\';\n\nexport const models = { };\nexport const routeMap = {};\n'
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
if (!existsSync(join(dist, 'src', 'policies.generated.mjs'))) {
|
|
2144
|
+
writeFileSync(
|
|
2145
|
+
join(dist, 'src', 'policies.generated.mjs'),
|
|
2146
|
+
'// generated by laravel-workers — do not edit\nexport const policies = {};\nexport const gateDefinitions = {};\n'
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
if (!existsSync(join(dist, 'src', 'classes.generated.mjs'))) {
|
|
2150
|
+
writeFileSync(
|
|
2151
|
+
join(dist, 'src', 'classes.generated.mjs'),
|
|
2152
|
+
'// generated by laravel-workers — do not edit\nexport const classes = {};\nexport const eventListeners = {};\nexport default classes;\n'
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// ---------- 3d. schema snapshot ----------
|
|
2157
|
+
// Keep the count so the success footer can name the D1 tables and remind the
|
|
2158
|
+
// operator that the schema statement must be applied ONCE by hand.
|
|
2159
|
+
let schemaTableCount = 0;
|
|
2160
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
2161
|
+
const schemaPath = join(cacheDir, 'schema.json');
|
|
2162
|
+
console.log('\u2022 dumping DB schema');
|
|
2163
|
+
execFileSync(PHP, [join(pkgDir, 'tools', 'schema-dump.php'), schemaPath], {
|
|
2164
|
+
cwd: appDir,
|
|
2165
|
+
env: childEnv(),
|
|
2166
|
+
});
|
|
2167
|
+
cpSync(schemaPath, join(dist, 'schema.json'));
|
|
2168
|
+
console.log(' -> dist/schema.json');
|
|
2169
|
+
|
|
2170
|
+
// The same schema as plain SQL, so the D1 tables can be created with one
|
|
2171
|
+
// command: `wrangler d1 execute DB --file=dist/schema.sql`. The blob store
|
|
2172
|
+
// table (the Storage facade on D1) is included when the app uses Storage::.
|
|
2173
|
+
{
|
|
2174
|
+
let tables = [];
|
|
2175
|
+
try {
|
|
2176
|
+
const snapshot = JSON.parse(readFileSync(schemaPath, 'utf8'));
|
|
2177
|
+
tables = (snapshot.tables || [])
|
|
2178
|
+
.map((t) => String(t.sql || '').trim().replace(/;+$/, ''))
|
|
2179
|
+
.filter(Boolean);
|
|
2180
|
+
schemaTableCount = tables.length;
|
|
2181
|
+
} catch {
|
|
2182
|
+
/* the snapshot is unreadable - dist/schema.json is still written */
|
|
2183
|
+
}
|
|
2184
|
+
const head =
|
|
2185
|
+
'-- laravel-workers: the D1 schema for this app\n' +
|
|
2186
|
+
'-- wrangler d1 execute DB --file=dist/schema.sql\n';
|
|
2187
|
+
const blob = usesStorage
|
|
2188
|
+
? '\n-- the Storage facade on D1 (one row per object)\n' + BLOB_DDL + ';\n'
|
|
2189
|
+
: '';
|
|
2190
|
+
writeFileSync(
|
|
2191
|
+
join(dist, 'schema.sql'),
|
|
2192
|
+
head + (tables.length ? tables.join(';\n') + ';\n' : '') + blob
|
|
2193
|
+
);
|
|
2194
|
+
console.log(
|
|
2195
|
+
` -> dist/schema.sql (${tables.length} table(s)` + (usesStorage ? ` + ${BLOB_TABLE}` : '') + ')'
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ---------- 4. copy static assets ----------
|
|
2200
|
+
for (const f of ['build', 'favicon.ico', 'robots.txt']) {
|
|
2201
|
+
const src = join(appDir, 'public', f);
|
|
2202
|
+
if (!existsSync(src)) {
|
|
2203
|
+
console.log(` ! public/${f} missing — skipped`);
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
cpSync(src, join(dist, 'public', f), { recursive: true });
|
|
2207
|
+
console.log(`• copied public/${f}`);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// ---------- 5. worker + ports + routes ----------
|
|
2211
|
+
const workerCandidates = [join(appDir, 'src/worker.mjs'), join(pkgDir, 'src/worker.mjs')];
|
|
2212
|
+
let workerSrc = null;
|
|
2213
|
+
for (const p of workerCandidates) if (existsSync(p)) { workerSrc = p; break; }
|
|
2214
|
+
if (!workerSrc) fail('no worker template found — looked for src/worker.mjs in the app and in the package (reinstall laravel-workers)');
|
|
2215
|
+
cpSync(workerSrc, join(dist, 'src/worker.mjs'));
|
|
2216
|
+
const portsCandidates = [
|
|
2217
|
+
...(lwPortsDir ? [lwPortsDir] : []),
|
|
2218
|
+
join(appDir, 'src/ports'),
|
|
2219
|
+
join(appDir, 'ports'),
|
|
2220
|
+
join(appDir, 'edge/src/ports'),
|
|
2221
|
+
join(pkgDir, 'src/ports'),
|
|
2222
|
+
];
|
|
2223
|
+
let portsSrc = null;
|
|
2224
|
+
for (const p of portsCandidates) if (existsSync(p)) { portsSrc = p; break; }
|
|
2225
|
+
if (!portsSrc) portsSrc = join(pkgDir, 'src/ports');
|
|
2226
|
+
if (portsSrc === lwPortsDir) console.log(`• ports: LW_PORTS_DIR -> ${portsSrc}`);
|
|
2227
|
+
const missingPortFiles = portEntries
|
|
2228
|
+
.map(([route, name]) => [route, String(name)])
|
|
2229
|
+
.filter(([, slug]) => !existsSync(join(portsSrc, slug + '.mjs')));
|
|
2230
|
+
if (missingPortFiles.length)
|
|
2231
|
+
fail(
|
|
2232
|
+
'port file(s) missing in ' + portsSrc + ': ' +
|
|
2233
|
+
missingPortFiles.map(([route, slug]) => `${route} -> ${slug}.mjs`).join(', ') +
|
|
2234
|
+
' — create them, fix the "ports" mapping in registry.json, or point LW_PORTS_DIR at the right directory'
|
|
2235
|
+
);
|
|
2236
|
+
if (existsSync(portsSrc)) {
|
|
2237
|
+
cpSync(portsSrc, join(dist, 'src/ports'), { recursive: true });
|
|
2238
|
+
} else {
|
|
2239
|
+
mkdirSync(join(dist, 'src/ports'), { recursive: true });
|
|
2240
|
+
console.log(' ! no ports directory found — created empty dist/src/ports');
|
|
2241
|
+
}
|
|
2242
|
+
cpSync(join(pkgDir, 'kernel'), join(dist, 'kernel'), { recursive: true });
|
|
2243
|
+
// kernel/auth.mjs and kernel/hash.mjs import bcryptjs (pure JS, WebCrypto
|
|
2244
|
+
// fallback). The dist tree is what Wrangler bundles, so the package must live
|
|
2245
|
+
// inside it: a bare `bcryptjs` import from <app>/dist resolves only if
|
|
2246
|
+
// <app>/dist/node_modules/bcryptjs exists (the app itself need not depend on it).
|
|
2247
|
+
const bcryptSrc = [join(pkgDir, 'node_modules', 'bcryptjs'), join(appDir, 'node_modules', 'bcryptjs')].find((p) =>
|
|
2248
|
+
existsSync(join(p, 'index.js'))
|
|
2249
|
+
);
|
|
2250
|
+
if (bcryptSrc) {
|
|
2251
|
+
cpSync(bcryptSrc, join(dist, 'node_modules', 'bcryptjs'), { recursive: true });
|
|
2252
|
+
} else {
|
|
2253
|
+
fail(
|
|
2254
|
+
'bcryptjs is missing — kernel/auth.mjs and kernel/hash.mjs import it, and the dist bundle cannot resolve it.\n' +
|
|
2255
|
+
' Reinstall laravel-workers (npm i) or add bcryptjs to the app and run npm i.'
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
cpSync(join(pkgDir, 'compiler', 'runtime'), join(dist, 'src', 'runtime'), { recursive: true });
|
|
2259
|
+
|
|
2260
|
+
const builtAt = new Date().toISOString();
|
|
2261
|
+
// A uri's base entry answers only its main verb; extra verbs (POST on a
|
|
2262
|
+
// snapshotted GET uri, etc.) get their own table entry below so router.routeKey()
|
|
2263
|
+
// — which keys a handler by the entry's first method — resolves each verb.
|
|
2264
|
+
const baseMethodsFor = (route) => {
|
|
2265
|
+
const all = [...(methodsFor.get(route) || new Set())];
|
|
2266
|
+
const extras = extraVerbsFor(route);
|
|
2267
|
+
if (!extras.length) return all;
|
|
2268
|
+
const kept = all.filter((m) => !extras.includes(m));
|
|
2269
|
+
return kept.length ? kept : all;
|
|
2270
|
+
};
|
|
2271
|
+
const routeTable = routes.map((route) => {
|
|
2272
|
+
if (ignoreMap.some(([r]) => r === route)) {
|
|
2273
|
+
const entry = ignoreMap.find(([r]) => r === route);
|
|
2274
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'ignore', reason: entry[1] };
|
|
2275
|
+
}
|
|
2276
|
+
if (portEntries.some(([r]) => r === route)) {
|
|
2277
|
+
const slug = portEntries.find(([r]) => r === route)[1];
|
|
2278
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'port', controller: slug };
|
|
2279
|
+
}
|
|
2280
|
+
if (viewMap.some(([r]) => r === route)) {
|
|
2281
|
+
const slug = controllerByRoute.get(route);
|
|
2282
|
+
const view = viewMap.find(([r]) => r === route)[1];
|
|
2283
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'view', view, controller: slug };
|
|
2284
|
+
}
|
|
2285
|
+
if (controllerRoutes.has(route)) {
|
|
2286
|
+
const info = controllerRoutes.get(route);
|
|
2287
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'controller', controller: info.slug };
|
|
2288
|
+
}
|
|
2289
|
+
if (staticMap.some(([r]) => r === route)) {
|
|
2290
|
+
const file = staticMap.find(([r]) => r === route)[1];
|
|
2291
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'snapshot', snapshot: file };
|
|
2292
|
+
}
|
|
2293
|
+
if (snapshots.includes(route)) {
|
|
2294
|
+
const file = route === '/' ? 'index.html' : route.slice(1) + '/index.html';
|
|
2295
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'snapshot', snapshot: file };
|
|
2296
|
+
}
|
|
2297
|
+
return { uri: route, ...metaForRoute(route), methods: baseMethodsFor(route), kind: 'unhandled' };
|
|
2298
|
+
});
|
|
2299
|
+
for (const v of verbControllers) {
|
|
2300
|
+
const meta = metaForVerb(v.route, v.verb) || {};
|
|
2301
|
+
routeTable.push({
|
|
2302
|
+
uri: v.route,
|
|
2303
|
+
name: meta.name ?? null,
|
|
2304
|
+
domain: meta.domain ?? null,
|
|
2305
|
+
middleware: meta.middleware ?? null,
|
|
2306
|
+
methods: [v.verb],
|
|
2307
|
+
kind: 'controller',
|
|
2308
|
+
controller: v.slug,
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// ---------- 5a. middleware audit ----------
|
|
2313
|
+
// kernel/http.mjs honours exactly this set. Anything else on a view / controller /
|
|
2314
|
+
// snapshot route would be bypassed at the edge — fail the build instead.
|
|
2315
|
+
{
|
|
2316
|
+
const isNumericThrottle = (m) => {
|
|
2317
|
+
const str = String(m);
|
|
2318
|
+
if (str.split(':')[0] !== 'throttle') return false;
|
|
2319
|
+
const spec = str.slice('throttle'.length).replace(/^:/, '');
|
|
2320
|
+
return spec === '' || /^\d+(,\d+)?$/.test(spec);
|
|
2321
|
+
};
|
|
2322
|
+
const RUNTIME_MIDDLEWARE = new Set(['web', 'api', 'substitute_bindings', 'bindings', 'auth', 'auth.basic', 'guest']);
|
|
2323
|
+
const offenders = [];
|
|
2324
|
+
let throttleRoutes = 0;
|
|
2325
|
+
for (const entry of routeTable) {
|
|
2326
|
+
if (entry.kind !== 'view' && entry.kind !== 'controller' && entry.kind !== 'snapshot') continue;
|
|
2327
|
+
const bad = (entry.middleware || []).filter(
|
|
2328
|
+
(m) => !RUNTIME_MIDDLEWARE.has(String(m).split(':')[0]) && !isNumericThrottle(m)
|
|
2329
|
+
);
|
|
2330
|
+
if (bad.length) {
|
|
2331
|
+
offenders.push(`${entry.uri} [${bad.join(', ')}] (${entry.kind})`);
|
|
2332
|
+
for (const m of bad) note({ route: entry.uri, area: 'middleware', severity: BLOCK, cause: `runs the "${m}" middleware, which the compiled rung does not honour`, fix: MIDDLEWARE_FIX(m, entry.uri) });
|
|
2333
|
+
}
|
|
2334
|
+
if ((entry.middleware || []).some(isNumericThrottle)) throttleRoutes++;
|
|
2335
|
+
}
|
|
2336
|
+
if (offenders.length) {
|
|
2337
|
+
fail(
|
|
2338
|
+
'route middleware the compiled rung does not honour:\n - ' +
|
|
2339
|
+
offenders.join('\n - ') +
|
|
2340
|
+
'\n add a port for these routes, or drop the middleware.\n' +
|
|
2341
|
+
' (named rate limiters — throttle:name — need their AppServiceProvider\n' +
|
|
2342
|
+
' definition; the build only honours numeric throttle:n,m)'
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
// `throttle` reads/writes the D1-backed `database` cache store. Without it,
|
|
2346
|
+
// the compiled limiter 500s at request time — but the operator can rewrite
|
|
2347
|
+
// the app (set CACHE_STORE=database) to make it work. So this is a declared
|
|
2348
|
+
// gap, not a hard block: the build ships, and the operator knows exactly
|
|
2349
|
+
// what to do to close the gap.
|
|
2350
|
+
if (throttleRoutes && runtimeConfig['cache.driver'] !== 'database') {
|
|
2351
|
+
note({ route: null, area: 'config', severity: DECLARED, cause: `${throttleRoutes} route(s) use throttle middleware, but cache.driver is '${runtimeConfig['cache.driver']}' — the compiled limiter needs the D1-backed 'database' store`, fix: 'set CACHE_STORE=database and migrate the cache + cache_locks tables (the free path) — or add a port for the throttled routes. Until then the throttled routes 500 at request time.', paths: [{ tier: 'free', what: 'set CACHE_STORE=database in the app and migrate the cache + cache_locks tables — the compiled limiter then reads/writes D1' }, { tier: 'free', what: 'or add a port for the throttled route(s) — the port handles the throttling' }] });
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const controllerImports = [];
|
|
2356
|
+
const controllerMap = [];
|
|
2357
|
+
let controllerIndex = 0;
|
|
2358
|
+
for (const entry of routeTable) {
|
|
2359
|
+
if (entry.kind === 'view' || entry.kind === 'port' || entry.kind === 'controller') {
|
|
2360
|
+
const idx = controllerIndex++;
|
|
2361
|
+
controllerImports.push(`import handler${idx} from './controllers/${entry.controller}.mjs';`);
|
|
2362
|
+
controllerMap.push({ uri: entry.uri, methods: entry.methods, idx });
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const routeNames = {};
|
|
2367
|
+
for (const [key, meta] of metaFor) {
|
|
2368
|
+
if (meta.name) routeNames[meta.name] = key.slice(key.indexOf(' ') + 1);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
const handlersLines = controllerMap.map(({ uri, methods, idx }) => {
|
|
2372
|
+
const m = methods && methods.length ? methods : ['GET'];
|
|
2373
|
+
return ` ${JSON.stringify(m[0] + ' ' + uri)}: handler${idx},`;
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
writeFileSync(
|
|
2377
|
+
join(dist, 'src', 'routes.generated.mjs'),
|
|
2378
|
+
[
|
|
2379
|
+
'// generated by build.mjs — do not edit',
|
|
2380
|
+
...controllerImports,
|
|
2381
|
+
`export const builtAt = ${JSON.stringify(builtAt)};`,
|
|
2382
|
+
'export const routeTable = ' + JSON.stringify(routeTable, null, 2) + ';',
|
|
2383
|
+
'export const routeNames = ' + JSON.stringify(routeNames, null, 2) + ';',
|
|
2384
|
+
'export const handlers = {',
|
|
2385
|
+
...handlersLines,
|
|
2386
|
+
'};',
|
|
2387
|
+
'',
|
|
2388
|
+
].join('\n')
|
|
2389
|
+
);
|
|
2390
|
+
// ---------- 5b. Cloudflare Static Assets routing ----------
|
|
2391
|
+
mkdirSync(join(dist, 'public'), { recursive: true });
|
|
2392
|
+
const routesJson = buildRoutesJson(routeTable, { publicDirs: ['build'], extraInclude: ['/health'] });
|
|
2393
|
+
writeFileSync(join(dist, 'public', '_routes.json'), JSON.stringify(routesJson, null, 2) + '\n');
|
|
2394
|
+
console.log(` -> dist/public/_routes.json (${(routesJson.include || []).length} include, ${(routesJson.exclude || []).length} exclude)`);
|
|
2395
|
+
|
|
2396
|
+
// The action for an unhandled route. The reason already says WHY; this says
|
|
2397
|
+
// WHAT TO DO, and the four cases are genuinely different (a dynamic segment is
|
|
2398
|
+
// a five-minute port; a route that answers 500 at build time is a bug in the
|
|
2399
|
+
// app that the build just found for free).
|
|
2400
|
+
const needsPortFix = (n) => {
|
|
2401
|
+
const reason = String(n.reason || '');
|
|
2402
|
+
const ignore = `{"ignore": {"${n.route}": "<why>"}}`;
|
|
2403
|
+
if (reason.startsWith('dynamic segment')) return `write a port — the worker router matches dynamic segments and hands them to the handler as ctx.params (build --scaffold creates the file), or ${ignore}`;
|
|
2404
|
+
if (reason.startsWith('no GET')) return `the route has no GET to snapshot — write a port for the verb(s) it does answer, or ${ignore}`;
|
|
2405
|
+
if (reason.startsWith('unhandled (autoSnapshot: false)')) return `registry.json sets "autoSnapshot": false, so nothing is snapshotted — write a port, or set autoSnapshot back to true`;
|
|
2406
|
+
if (reason.startsWith('a controller serves it')) return 'the controller did not compile — the reason is on its `controller ... skipped` line above; fix that, or write a port';
|
|
2407
|
+
if (reason.startsWith('HTTP 5')) {
|
|
2408
|
+
const dbHint = /no such table/i.test(reason)
|
|
2409
|
+
? ' — the database is missing tables: run `php artisan migrate` first (a fresh clone starts with an empty database)'
|
|
2410
|
+
: '';
|
|
2411
|
+
return `the route throws when the app boots at build time (${reason}) — this is a bug the build found for free; fix the route, then rebuild${dbHint}`;
|
|
2412
|
+
}
|
|
2413
|
+
if (reason.startsWith('HTTP 3')) return `the route redirects (${reason}) — a redirect needs a handler: write a port that returns it, or ${ignore}`;
|
|
2414
|
+
if (reason.startsWith('HTTP 4')) return `the route answers ${reason} for a bare GET — it probably needs a parameter or auth; write a port, or ${ignore}`;
|
|
2415
|
+
if (reason.startsWith('HTTP')) return `the response is not an HTML page (${reason}) — write a port that returns it (a JSON or file body), or ${ignore}`;
|
|
2416
|
+
return `write a port (build --scaffold), or ${ignore}`;
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// ---------- 6. coverage report ----------
|
|
2420
|
+
const unknown = [
|
|
2421
|
+
...new Set([
|
|
2422
|
+
...staticMap.map(([r]) => r),
|
|
2423
|
+
...portEntries.map(([r]) => r),
|
|
2424
|
+
...viewMap.map(([r]) => r),
|
|
2425
|
+
...ignoreMap.map(([r]) => r),
|
|
2426
|
+
]),
|
|
2427
|
+
].filter((r) => !routes.includes(r));
|
|
2428
|
+
const stubPorts = findStubPorts(portsSrc);
|
|
2429
|
+
const runtimeBinding = needsPort.filter((n) => isRuntimeBindingError(n.reason));
|
|
2430
|
+
const blocked = (needsPort.length - runtimeBinding.length) > 0 || partials.length > 0 || unknown.length > 0 || stubPorts.length > 0 || uncompiledExtraNonPost.length > 0;
|
|
2431
|
+
|
|
2432
|
+
if (snapshots.length) console.log(`• auto-snapshots (${snapshots.length}): ${snapshots.join(', ')}`);
|
|
2433
|
+
for (const p of partials)
|
|
2434
|
+
console.log(` WARN ${p.route}: GET is snapshotted, but ${p.methods.join(', ')} needs a port`);
|
|
2435
|
+
for (const x of uncompiledExtraNonPost)
|
|
2436
|
+
console.log(` WARN ${x.route}: ${x.verb} needs a port (only GET/POST handlers are compiled)`);
|
|
2437
|
+
for (const n of needsPort) console.log(` UNHANDLED ${n.route}: ${n.reason}`);
|
|
2438
|
+
if (needsPort.length)
|
|
2439
|
+
console.log(' -> add a port, a "static" entry, or "ignore" (with a reason) in registry.json');
|
|
2440
|
+
if (unknown.length) console.log(` registry entries not found in route:list: ${unknown.join(', ')}`);
|
|
2441
|
+
if (stubPorts.length)
|
|
2442
|
+
console.log(
|
|
2443
|
+
` STUB ${stubPorts.length} port(s) not implemented yet (returning 501): ${stubPorts
|
|
2444
|
+
.map((n) => relative(appDir, join(portsSrc, n)))
|
|
2445
|
+
.join(', ')}`
|
|
2446
|
+
);
|
|
2447
|
+
if (!blocked)
|
|
2448
|
+
console.log(
|
|
2449
|
+
`• coverage: all ${routes.length} route(s) handled — static ${staticMap.length}, snapshots ${snapshots.length}, ports ${portEntries.length}, controllers ${controllerRoutes.size}, extra-verb controllers ${verbControllers.length}, ignored ${ignoreMap.length}`
|
|
2450
|
+
);
|
|
2451
|
+
|
|
2452
|
+
// Every undecided route becomes a finding with the action that resolves it.
|
|
2453
|
+
// The reasons above are already specific ("dynamic segment", "HTTP 500"); what
|
|
2454
|
+
// the operator lacks is the STEP, so each row names one.
|
|
2455
|
+
for (const n of needsPort) {
|
|
2456
|
+
if (isRuntimeBindingError(n.reason)) {
|
|
2457
|
+
note({ route: n.route, area: 'route', severity: DECLARED, cause: `needs a runtime binding (auto-gap): ${n.reason}`, fix: 'declare the binding in wrangler.jsonc (d1_databases) or set the env at deploy time; the build cannot prerender a route that needs a live connection' });
|
|
2458
|
+
} else {
|
|
2459
|
+
note({ route: n.route, area: 'route', severity: BLOCK, cause: `no handler: ${n.reason}`, fix: needsPortFix(n) });
|
|
2460
|
+
}
|
|
2461
|
+
// A route that never became a snapshot or a controller skips the middleware
|
|
2462
|
+
// audit above (that one walks the route TABLE), so its middleware is never
|
|
2463
|
+
// named - and the reason it could not be served is usually the middleware,
|
|
2464
|
+
// not the status it happened to answer. `verified` reads as "HTTP 500"; the
|
|
2465
|
+
// truth is a stage the compiled rung does not run.
|
|
2466
|
+
for (const m of middlewareForRoute(n.route) || []) {
|
|
2467
|
+
if (isHonouredMiddleware(m)) continue;
|
|
2468
|
+
note({ route: n.route, area: 'middleware', severity: BLOCK, cause: `runs the "${m}" middleware, which the compiled rung does not honour`, fix: MIDDLEWARE_FIX(m, n.route) });
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
for (const p of partials) note({ route: p.route, area: 'route', severity: BLOCK, cause: `GET is snapshotted, but ${p.methods.join(', ')} has no handler`, fix: `write a port for ${p.methods.join('/')} ${p.route} (build --scaffold), or ignore the route with a reason` });
|
|
2472
|
+
for (const x of uncompiledExtraNonPost) note({ route: x.route, area: 'route', severity: BLOCK, cause: `${x.verb} has no handler (only GET/POST/PUT/PATCH/DELETE compile)`, fix: `write a port for ${x.verb} ${x.route}, or ignore the route with a reason` });
|
|
2473
|
+
for (const u of unknown) note({ route: u, area: 'registry', severity: BLOCK, cause: 'registry.json names this route, but `artisan route:list` has no such route', fix: 'fix the path in registry.json (it must match route:list exactly), or delete the entry' });
|
|
2474
|
+
for (const ss of stubPorts) note({ route: null, area: 'route', severity: BLOCK, cause: `the port ${relPath(join(portsSrc, ss))} is still the scaffold stub (it answers 501)`, fix: 'implement the handler against env.DB (or another binding), then re-run the build' });
|
|
2475
|
+
// An `ignore` is a decision, not a failure: it ships, so it is reported as a
|
|
2476
|
+
// declared gap and never changes a green build into a red one.
|
|
2477
|
+
for (const [route, reason] of ignoreMap) note({ route, area: 'route', severity: DECLARED, cause: `ignored -- ${reason || 'no reason given'}`, fix: reason ? null : 'registry.json: give the "ignore" entry a reason (the next reader needs it)' });
|
|
2478
|
+
|
|
2479
|
+
|
|
2480
|
+
// ---------- 6b. app-source sweep (advisory only) ----------------------------
|
|
2481
|
+
// The compiler is the authority on what compiles. This sweep is not: it names
|
|
2482
|
+
// constructs the sandbox CANNOT ever carry even if the route is compiled, so
|
|
2483
|
+
// the operator fixes them before hitting the first build failure. Advisory
|
|
2484
|
+
// means severity = ADVISORY: green builds are not turned red by this list.
|
|
2485
|
+
// We walk only app/ (not vendor/) because everything outside the app is
|
|
2486
|
+
// framework code -- the build already refuses or ignores it correctly.
|
|
2487
|
+
{
|
|
2488
|
+
const sweepDir = join(appDir, 'app');
|
|
2489
|
+
if (existsSync(sweepDir)) {
|
|
2490
|
+
const files = [];
|
|
2491
|
+
const walk = (d) => {
|
|
2492
|
+
for (const e of readdirSync(d)) {
|
|
2493
|
+
const p = join(d, e);
|
|
2494
|
+
if (lstatSync(p).isDirectory()) {
|
|
2495
|
+
walk(p);
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
if (!e.endsWith('.php')) continue;
|
|
2499
|
+
try {
|
|
2500
|
+
files.push({ file: relPath(p), source: readFileSync(p, 'utf8') });
|
|
2501
|
+
} catch {
|
|
2502
|
+
/* unreadable file: not a build input */
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
};
|
|
2506
|
+
walk(sweepDir);
|
|
2507
|
+
for (const f of sweepAppSources(files)) note(f);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// ---------- 7. scaffold stubs for routes that need a port ----------
|
|
2512
|
+
|
|
2513
|
+
if (scaffold && needsPort.length) {
|
|
2514
|
+
const slugify = (route) =>
|
|
2515
|
+
route.replace(/^\//, '').replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'home';
|
|
2516
|
+
const entries = needsPort.map(({ route }) => ({ route, slug: slugify(route) }));
|
|
2517
|
+
for (const { route, slug } of entries) {
|
|
2518
|
+
const stubDir = isNpmMode ? (portsSrc !== join(pkgDir, 'src/ports') ? portsSrc : join(appDir, 'src/ports')) : join(pkgDir, 'src/ports');
|
|
2519
|
+
mkdirSync(stubDir, { recursive: true });
|
|
2520
|
+
const stub = join(stubDir, `${slug}.mjs`);
|
|
2521
|
+
if (existsSync(stub)) {
|
|
2522
|
+
console.log(` scaffold: ${relative(appDir, stub)} already exists`);
|
|
2523
|
+
} else {
|
|
2524
|
+
writeFileSync(stub, buildStubSource(route));
|
|
2525
|
+
console.log(` scaffolded ${relative(appDir, stub)} (${STUB_MARKER} — build --strict fails until implemented)`);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
const { registry: nextRegistry, added } = mergePorts(registry, entries);
|
|
2529
|
+
if (added.length) {
|
|
2530
|
+
writeFileSync(registryPath, serializeRegistry(nextRegistry));
|
|
2531
|
+
for (const { route, slug } of added) {
|
|
2532
|
+
console.log(` registry.json: "ports" += ${JSON.stringify(route)}: ${JSON.stringify(slug)}`);
|
|
2533
|
+
}
|
|
2534
|
+
} else {
|
|
2535
|
+
console.log(' registry.json: all scaffolded ports already registered');
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
if (blocked && !scaffold) {
|
|
2539
|
+
if (needsPort.length) console.log(' (tip: re-run with --scaffold to generate + register port stubs)');
|
|
2540
|
+
else if (stubPorts.length) console.log(' (tip: implement the stub port(s), then re-run)');
|
|
2541
|
+
}
|
|
2542
|
+
if (strict && blocked) {
|
|
2543
|
+
const stubNote = stubPorts.length
|
|
2544
|
+
? ` — ${stubPorts.length} port(s) are still scaffold stubs (returning 501): ${stubPorts.join(', ')}`
|
|
2545
|
+
: '';
|
|
2546
|
+
// Count the findings themselves, not the route arrays - the routes were
|
|
2547
|
+
// re-checked into multiple findings (one per unresolved middleware), and
|
|
2548
|
+
// that's what the readiness block above prints.
|
|
2549
|
+
const blocking = findings.filter((f) => f.severity === BLOCK).length;
|
|
2550
|
+
fail(
|
|
2551
|
+
'coverage check failed (--strict)' +
|
|
2552
|
+
` — ${blocking} finding(s) need a decision, listed above` +
|
|
2553
|
+
(scaffold ? ' — port stubs were scaffolded + registered; implement them, then re-run' : '') +
|
|
2554
|
+
stubNote
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// A queued job with no cron trigger never runs: the route would answer 200 and
|
|
2559
|
+
// the work would silently never happen (Laravel runs it). This runs LAST -
|
|
2560
|
+
// `queuePushes` is filled while the controllers are emitted.
|
|
2561
|
+
if (queuePushes.length && !(cronTriggers && cronTriggers.length)) {
|
|
2562
|
+
for (const route of queuePushes) note({ route, area: 'queue', severity: BLOCK, cause: 'dispatches a queued job, but the worker has no cron trigger', fix: 'add `[triggers] crons = ["*/1 * * * *"]` to wrangler.toml (a Worker has no long-lived consumer), or dispatch the job inline (QUEUE_CONNECTION=sync)', paths: [{ tier: 'free', what: 'add `[triggers] crons = ["*/1 * * * *"]` to wrangler.toml - the compiled rung writes the job to the D1 lw_jobs table and the cron trigger drains it (free accounts get 5 triggers)' }, { tier: 'free', what: 'or dispatch inline with QUEUE_CONNECTION=sync when the work is small enough to finish in the request' }, { tier: 'paid', what: 'Cloudflare Queues with a consumer you write - charged per operation' }] });
|
|
2563
|
+
fail(
|
|
2564
|
+
'the route(s) ' + queuePushes.join(', ') + ' dispatch a queued job, but the worker has no cron trigger - ' +
|
|
2565
|
+
'a Worker has no long-lived queue consumer, so the job would never run. Add a cron trigger ' +
|
|
2566
|
+
'(wrangler.toml `[triggers] crons = ["*/1 * * * *"]`) or dispatch the job inline ' +
|
|
2567
|
+
"(Laravel's sync driver) instead"
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// ---------- the swap ----------
|
|
2572
|
+
// Every check passed and every artifact landed in the staging directory: move
|
|
2573
|
+
// the previous `dist/` aside and put the new one in its place. A build that
|
|
2574
|
+
// failed above never reaches this line, so `dist/` still holds the last build
|
|
2575
|
+
// that COMPLETED (compiler/src/dist-swap.mjs).
|
|
2576
|
+
commitDist(dist, distFinal, (file) =>
|
|
2577
|
+
console.log(
|
|
2578
|
+
` (warning: ${relative(appDir, file)} is locked by a running process — restart the dev server if build output looks stale)`
|
|
2579
|
+
)
|
|
2580
|
+
);
|
|
2581
|
+
|
|
2582
|
+
console.log(
|
|
2583
|
+
`done. dist: ${relative(appDir, distFinal)} (static: ${staticMap.length}, snapshots: ${snapshots.length}, ports: ${portEntries.length}, controllers: ${controllerRoutes.size}, ignored: ${ignoreMap.length})`
|
|
2584
|
+
);
|
|
2585
|
+
|
|
2586
|
+
// The build is green: say what that green MEANS. A green build with two
|
|
2587
|
+
// ignored routes is not the same product as one with none, and the operator
|
|
2588
|
+
// must see the difference here - not discover it at the edge.
|
|
2589
|
+
console.log(renderReadiness(findings));
|
|
2590
|
+
|
|
2591
|
+
// The one thing every first deploy needs and no build can do for the operator:
|
|
2592
|
+
// the D1 tables. Printed only when there is a schema to apply.
|
|
2593
|
+
if (schemaTableCount) {
|
|
2594
|
+
console.log(
|
|
2595
|
+
` schema: ${relPath(join(distFinal, 'schema.sql'))} (${schemaTableCount} table(s)) -- apply ONCE, by hand:\n` +
|
|
2596
|
+
' npx wrangler d1 execute DB --file=dist/schema.sql --remote'
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
console.log(' next: npx wrangler dev (local) npx wrangler deploy (edge)');
|