laravel-workers 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/bin/laravel-workers.mjs +141 -141
- package/build.mjs +2599 -2599
- package/compiler/bin/controller.mjs +92 -92
- package/kernel/cache.mjs +405 -405
- package/kernel/db.mjs +703 -703
- package/kernel/encrypter.mjs +1 -1
- package/kernel/hash.mjs +113 -113
- package/kernel/multipart.mjs +161 -161
- package/kernel/router.mjs +276 -276
- package/kernel/scheduler.mjs +236 -236
- package/kernel/str.mjs +445 -445
- package/kernel/tests/cache.test.mjs +290 -290
- package/kernel/tests/db.test.mjs +1068 -1068
- package/kernel/tests/encrypter.test.mjs +1 -1
- package/kernel/tests/hash.test.mjs +149 -149
- package/kernel/tests/http.test.mjs +1182 -1182
- package/kernel/tests/php-json.test.mjs +263 -263
- package/kernel/tests/router.test.mjs +281 -281
- package/kernel/tests/services.test.mjs +474 -474
- package/kernel/tests/str.test.mjs +83 -83
- package/kernel/tests/validation.test.mjs +340 -340
- package/kernel/validation.mjs +123 -123
- package/package.json +69 -69
- package/tools/schema-dump.php +158 -158
package/kernel/router.mjs
CHANGED
|
@@ -1,276 +1,276 @@
|
|
|
1
|
-
// kernel/router.mjs — Workers-native router
|
|
2
|
-
function normalizeUri(uri) {
|
|
3
|
-
let u = uri == null ? '' : String(uri);
|
|
4
|
-
if (u === '') u = '/';
|
|
5
|
-
if (!u.startsWith('/')) u = '/' + u;
|
|
6
|
-
return u;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function arraysEqual(a, b) {
|
|
10
|
-
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
11
|
-
if (a.length !== b.length) return false;
|
|
12
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
13
|
-
return true;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function normalizeTable(routes) {
|
|
17
|
-
if (!Array.isArray(routes)) throw new Error('route table must be an array');
|
|
18
|
-
const out = [];
|
|
19
|
-
for (const r of routes) {
|
|
20
|
-
if (!r || typeof r !== 'object') throw new Error('invalid route entry');
|
|
21
|
-
const uri = normalizeUri(r.uri);
|
|
22
|
-
const methods = (r.methods || []).map((m) => String(m).toUpperCase());
|
|
23
|
-
if (methods.length === 0) throw new Error('route has no methods: ' + uri);
|
|
24
|
-
const name = r.name == null ? null : String(r.name);
|
|
25
|
-
const domain = r.domain == null ? null : String(r.domain);
|
|
26
|
-
const entry = { uri, name, methods, domain };
|
|
27
|
-
if (r.bindings != null) entry.bindings = r.bindings;
|
|
28
|
-
if (r.middleware != null) entry.middleware = r.middleware;
|
|
29
|
-
|
|
30
|
-
// later registration wins for same uri+methods
|
|
31
|
-
let replaced = false;
|
|
32
|
-
for (let i = out.length - 1; i >= 0; i--) {
|
|
33
|
-
if (out[i].uri === uri && arraysEqual(out[i].methods, methods)) {
|
|
34
|
-
out[i] = entry;
|
|
35
|
-
replaced = true;
|
|
36
|
-
break;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (!replaced) out.push(entry);
|
|
40
|
-
}
|
|
41
|
-
return out;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function routeKey(route) {
|
|
45
|
-
const methods = (route && route.methods) || [];
|
|
46
|
-
const m = methods.length ? String(methods[0]).toUpperCase() : 'GET';
|
|
47
|
-
return m + ' ' + normalizeUri(route && route.uri);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function escapeRegex(s) {
|
|
51
|
-
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function uriToRegex(uri) {
|
|
55
|
-
const norm = normalizeUri(uri);
|
|
56
|
-
const segs = norm.split('/');
|
|
57
|
-
// handle root: '' segments
|
|
58
|
-
let pattern = '';
|
|
59
|
-
for (let i = 0; i < segs.length; i++) {
|
|
60
|
-
const seg = segs[i];
|
|
61
|
-
const wild = seg.match(/^\{(\w+)\*\}$/);
|
|
62
|
-
if (wild) {
|
|
63
|
-
if (i !== segs.length - 1) {
|
|
64
|
-
throw new Error('wildcard {param*} must be the last segment: ' + norm);
|
|
65
|
-
}
|
|
66
|
-
pattern += (i === 0 ? '' : '/') + '(?<' + wild[1] + '>.+)';
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
const m = seg.match(/^\{(\w+)\??\}$/);
|
|
70
|
-
if (m) {
|
|
71
|
-
const name = m[1];
|
|
72
|
-
const optional = seg.endsWith('?}');
|
|
73
|
-
if (optional) {
|
|
74
|
-
// Symfony's RouteCompiler: an optional variable becomes a subpattern
|
|
75
|
-
// ("Enclose each optional token in a subpattern to make it optional"),
|
|
76
|
-
// so '/users/{id?}' also answers '/users'. The separator goes INSIDE
|
|
77
|
-
// the group - except when the optional variable is the only token
|
|
78
|
-
// (the separator is required there) or when it is not the last one
|
|
79
|
-
// (Symfony's first-optional scan stops at the text token after it, so
|
|
80
|
-
// the variable is required in the regex).
|
|
81
|
-
if (i !== segs.length - 1) {
|
|
82
|
-
pattern += (i === 0 ? '' : '/') + '(?<' + name + '>[^/]+)';
|
|
83
|
-
} else if (segs.length === 2) {
|
|
84
|
-
pattern += '/(?<' + name + '>[^/]+)?';
|
|
85
|
-
} else {
|
|
86
|
-
pattern += '(?:/(?<' + name + '>[^/]+))?';
|
|
87
|
-
}
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
pattern += (i === 0 ? '' : '/') + '(?<' + name + '>[^/]+)';
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
pattern += (i === 0 ? '' : '/') + escapeRegex(seg);
|
|
94
|
-
}
|
|
95
|
-
return new RegExp('^' + pattern + '/?$');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function domainToRegex(domain) {
|
|
99
|
-
const parts = String(domain).split('.').map((seg) => {
|
|
100
|
-
const m = seg.match(/^\{(\w+)\}$/);
|
|
101
|
-
if (m) return '(?<' + m[1] + '>[^.]+)';
|
|
102
|
-
if (seg === '*') return '[^.]+';
|
|
103
|
-
return escapeRegex(seg);
|
|
104
|
-
});
|
|
105
|
-
return new RegExp('^' + parts.join('\\.') + '$', 'i');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function hostMatches(route, host) {
|
|
109
|
-
if (!route.domain) return true;
|
|
110
|
-
if (!host) return false;
|
|
111
|
-
const h = String(host).split(':')[0];
|
|
112
|
-
return domainToRegex(route.domain).test(h);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// PHP's rawurldecode(): the percent escapes are decoded, an invalid escape
|
|
116
|
-
// is left alone.
|
|
117
|
-
function rawurldecode(path) {
|
|
118
|
-
try {
|
|
119
|
-
return decodeURIComponent(path);
|
|
120
|
-
} catch {
|
|
121
|
-
return path.replace(/%[0-9A-Fa-f]{2}/g, (m) => {
|
|
122
|
-
try { return decodeURIComponent(m); } catch { return m; }
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// The path the route matches: the query string is out, the trailing slashes
|
|
128
|
-
// are trimmed and the escapes are decoded — Laravel's UriValidator does
|
|
129
|
-
// rtrim() first and rawurldecode() second.
|
|
130
|
-
function requestPath(req) {
|
|
131
|
-
let path = (req && req.path) || '/';
|
|
132
|
-
const qIdx = path.indexOf('?');
|
|
133
|
-
if (qIdx !== -1) path = path.slice(0, qIdx);
|
|
134
|
-
if (path.length > 1 && path.endsWith('/')) path = path.replace(/\/+$/, '');
|
|
135
|
-
return rawurldecode(path);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function resolveMethod(req) {
|
|
139
|
-
let method = String((req && req.method) || 'GET').toUpperCase();
|
|
140
|
-
if (req && req.methodOverride) return String(req.methodOverride).toUpperCase();
|
|
141
|
-
const headers = (req && req.headers) || {};
|
|
142
|
-
// Laravel's form spoofing, via header equivalent; only POST may be overridden.
|
|
143
|
-
const override = headers['x-http-method-override'] || headers['X-HTTP-Method-Override'];
|
|
144
|
-
if (override && method === 'POST') return String(override).toUpperCase();
|
|
145
|
-
return method;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function methodMatches(route, method) {
|
|
149
|
-
const m = String(method || 'GET').toUpperCase();
|
|
150
|
-
const methods = (route.methods || []).map((x) => String(x).toUpperCase());
|
|
151
|
-
if (methods.indexOf(m) !== -1) return true;
|
|
152
|
-
// HEAD answered by GET routes
|
|
153
|
-
if (m === 'HEAD' && methods.indexOf('GET') !== -1) return true;
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function match(table, req) {
|
|
158
|
-
const method = resolveMethod(req);
|
|
159
|
-
const path = requestPath(req);
|
|
160
|
-
|
|
161
|
-
const host = req && (req.host || req.hostname);
|
|
162
|
-
const tbl = Array.isArray(table) ? table : (table && table.routes) || [];
|
|
163
|
-
for (const route of tbl) {
|
|
164
|
-
if (!methodMatches(route, method)) continue;
|
|
165
|
-
if (!hostMatches(route, host)) continue;
|
|
166
|
-
const rx = uriToRegex(route.uri);
|
|
167
|
-
const m = rx.exec(path);
|
|
168
|
-
if (!m) continue;
|
|
169
|
-
const params = {};
|
|
170
|
-
if (m.groups) {
|
|
171
|
-
for (const k of Object.keys(m.groups)) {
|
|
172
|
-
// The path was decoded before it matched, so the capture is decoded.
|
|
173
|
-
if (m.groups[k] != null) params[k] = m.groups[k];
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (route.domain && host) {
|
|
177
|
-
const dm = domainToRegex(route.domain).exec(String(host).split(':')[0]);
|
|
178
|
-
if (dm && dm.groups) for (const k of Object.keys(dm.groups)) params[k] = dm.groups[k];
|
|
179
|
-
}
|
|
180
|
-
return { route, params };
|
|
181
|
-
}
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// The methods the URI accepts (Laravel's AbstractRouteCollection): the verbs
|
|
186
|
-
// (Router::$verbs, in Laravel's order) that have a route matching the URI —
|
|
187
|
-
// the method itself is left out. Laravel's router answers 405 (or the
|
|
188
|
-
// automatic OPTIONS response) when the URI matches a route but the method does
|
|
189
|
-
// not; the Allow header lists these verbs.
|
|
190
|
-
const ROUTER_VERBS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'QUERY'];
|
|
191
|
-
|
|
192
|
-
export function allowedMethods(table, req) {
|
|
193
|
-
const method = resolveMethod(req);
|
|
194
|
-
const path = requestPath(req);
|
|
195
|
-
const host = req && (req.host || req.hostname);
|
|
196
|
-
const tbl = Array.isArray(table) ? table : (table && table.routes) || [];
|
|
197
|
-
const verbs = [];
|
|
198
|
-
for (const verb of ROUTER_VERBS) {
|
|
199
|
-
if (verb === method) continue;
|
|
200
|
-
let found = false;
|
|
201
|
-
for (const route of tbl) {
|
|
202
|
-
const methods = (route.methods || []).map((m) => String(m).toUpperCase());
|
|
203
|
-
if (methods.indexOf(verb) === -1) continue;
|
|
204
|
-
if (!hostMatches(route, host)) continue;
|
|
205
|
-
let rx;
|
|
206
|
-
try {
|
|
207
|
-
rx = uriToRegex(route.uri);
|
|
208
|
-
} catch {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
if (rx.test(path)) {
|
|
212
|
-
found = true;
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (found) verbs.push(verb);
|
|
217
|
-
}
|
|
218
|
-
return verbs;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export async function dispatch(req, table, handlers, env, options = {}) {
|
|
222
|
-
const m = match(table, req);
|
|
223
|
-
if (!m) return null;
|
|
224
|
-
|
|
225
|
-
const key = routeKey(m.route);
|
|
226
|
-
let handler = null;
|
|
227
|
-
if (handlers) {
|
|
228
|
-
if (typeof handlers.get === 'function') handler = handlers.get(key);
|
|
229
|
-
else if (Object.prototype.hasOwnProperty.call(handlers, key)) handler = handlers[key];
|
|
230
|
-
}
|
|
231
|
-
if (typeof handler !== 'function') return null;
|
|
232
|
-
|
|
233
|
-
const ctx = {
|
|
234
|
-
params: m.params,
|
|
235
|
-
request: options.ctxRequest ?? {
|
|
236
|
-
method: resolveMethod(req),
|
|
237
|
-
path: (req && req.path) || '/',
|
|
238
|
-
headers: (req && req.headers) || {},
|
|
239
|
-
host: (req && (req.host || req.hostname)) || null,
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
Object.assign(ctx, options.ctxExtra || {});
|
|
243
|
-
const bindings = m.route && m.route.bindings;
|
|
244
|
-
if (bindings && options && options.models) {
|
|
245
|
-
ctx.models = {};
|
|
246
|
-
for (const name of Object.keys(bindings)) {
|
|
247
|
-
const b = bindings[name];
|
|
248
|
-
const modelName = b == null ? b : (b.model ?? b);
|
|
249
|
-
const Model = options.models[modelName];
|
|
250
|
-
if (!Model) {
|
|
251
|
-
const err = new Error('unknown model: ' + String(modelName));
|
|
252
|
-
err.status = 500;
|
|
253
|
-
throw err;
|
|
254
|
-
}
|
|
255
|
-
const column = (b == null) ? 'id' : (b.column || 'id');
|
|
256
|
-
const instance = await Model.query(options.db).where(column, ctx.params[name]).first();
|
|
257
|
-
if (!instance) {
|
|
258
|
-
const err = new Error('not found: ' + String(name));
|
|
259
|
-
err.status = 404;
|
|
260
|
-
throw err;
|
|
261
|
-
}
|
|
262
|
-
ctx.models[name] = instance;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
const res = await handler(ctx, env);
|
|
266
|
-
if (!res || typeof res.status !== 'number' || typeof res.body !== 'string') {
|
|
267
|
-
throw new Error('handler returned invalid response shape');
|
|
268
|
-
}
|
|
269
|
-
const headers = {};
|
|
270
|
-
if (res.headers && typeof res.headers === 'object') {
|
|
271
|
-
for (const k of Object.keys(res.headers)) headers[String(k).toLowerCase()] = res.headers[k];
|
|
272
|
-
}
|
|
273
|
-
return { status: res.status, headers, body: res.body };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export default { normalizeTable, routeKey, match, allowedMethods, dispatch };
|
|
1
|
+
// kernel/router.mjs — Workers-native router
|
|
2
|
+
function normalizeUri(uri) {
|
|
3
|
+
let u = uri == null ? '' : String(uri);
|
|
4
|
+
if (u === '') u = '/';
|
|
5
|
+
if (!u.startsWith('/')) u = '/' + u;
|
|
6
|
+
return u;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function arraysEqual(a, b) {
|
|
10
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
11
|
+
if (a.length !== b.length) return false;
|
|
12
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeTable(routes) {
|
|
17
|
+
if (!Array.isArray(routes)) throw new Error('route table must be an array');
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const r of routes) {
|
|
20
|
+
if (!r || typeof r !== 'object') throw new Error('invalid route entry');
|
|
21
|
+
const uri = normalizeUri(r.uri);
|
|
22
|
+
const methods = (r.methods || []).map((m) => String(m).toUpperCase());
|
|
23
|
+
if (methods.length === 0) throw new Error('route has no methods: ' + uri);
|
|
24
|
+
const name = r.name == null ? null : String(r.name);
|
|
25
|
+
const domain = r.domain == null ? null : String(r.domain);
|
|
26
|
+
const entry = { uri, name, methods, domain };
|
|
27
|
+
if (r.bindings != null) entry.bindings = r.bindings;
|
|
28
|
+
if (r.middleware != null) entry.middleware = r.middleware;
|
|
29
|
+
|
|
30
|
+
// later registration wins for same uri+methods
|
|
31
|
+
let replaced = false;
|
|
32
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
33
|
+
if (out[i].uri === uri && arraysEqual(out[i].methods, methods)) {
|
|
34
|
+
out[i] = entry;
|
|
35
|
+
replaced = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!replaced) out.push(entry);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function routeKey(route) {
|
|
45
|
+
const methods = (route && route.methods) || [];
|
|
46
|
+
const m = methods.length ? String(methods[0]).toUpperCase() : 'GET';
|
|
47
|
+
return m + ' ' + normalizeUri(route && route.uri);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeRegex(s) {
|
|
51
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function uriToRegex(uri) {
|
|
55
|
+
const norm = normalizeUri(uri);
|
|
56
|
+
const segs = norm.split('/');
|
|
57
|
+
// handle root: '' segments
|
|
58
|
+
let pattern = '';
|
|
59
|
+
for (let i = 0; i < segs.length; i++) {
|
|
60
|
+
const seg = segs[i];
|
|
61
|
+
const wild = seg.match(/^\{(\w+)\*\}$/);
|
|
62
|
+
if (wild) {
|
|
63
|
+
if (i !== segs.length - 1) {
|
|
64
|
+
throw new Error('wildcard {param*} must be the last segment: ' + norm);
|
|
65
|
+
}
|
|
66
|
+
pattern += (i === 0 ? '' : '/') + '(?<' + wild[1] + '>.+)';
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const m = seg.match(/^\{(\w+)\??\}$/);
|
|
70
|
+
if (m) {
|
|
71
|
+
const name = m[1];
|
|
72
|
+
const optional = seg.endsWith('?}');
|
|
73
|
+
if (optional) {
|
|
74
|
+
// Symfony's RouteCompiler: an optional variable becomes a subpattern
|
|
75
|
+
// ("Enclose each optional token in a subpattern to make it optional"),
|
|
76
|
+
// so '/users/{id?}' also answers '/users'. The separator goes INSIDE
|
|
77
|
+
// the group - except when the optional variable is the only token
|
|
78
|
+
// (the separator is required there) or when it is not the last one
|
|
79
|
+
// (Symfony's first-optional scan stops at the text token after it, so
|
|
80
|
+
// the variable is required in the regex).
|
|
81
|
+
if (i !== segs.length - 1) {
|
|
82
|
+
pattern += (i === 0 ? '' : '/') + '(?<' + name + '>[^/]+)';
|
|
83
|
+
} else if (segs.length === 2) {
|
|
84
|
+
pattern += '/(?<' + name + '>[^/]+)?';
|
|
85
|
+
} else {
|
|
86
|
+
pattern += '(?:/(?<' + name + '>[^/]+))?';
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
pattern += (i === 0 ? '' : '/') + '(?<' + name + '>[^/]+)';
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
pattern += (i === 0 ? '' : '/') + escapeRegex(seg);
|
|
94
|
+
}
|
|
95
|
+
return new RegExp('^' + pattern + '/?$');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function domainToRegex(domain) {
|
|
99
|
+
const parts = String(domain).split('.').map((seg) => {
|
|
100
|
+
const m = seg.match(/^\{(\w+)\}$/);
|
|
101
|
+
if (m) return '(?<' + m[1] + '>[^.]+)';
|
|
102
|
+
if (seg === '*') return '[^.]+';
|
|
103
|
+
return escapeRegex(seg);
|
|
104
|
+
});
|
|
105
|
+
return new RegExp('^' + parts.join('\\.') + '$', 'i');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hostMatches(route, host) {
|
|
109
|
+
if (!route.domain) return true;
|
|
110
|
+
if (!host) return false;
|
|
111
|
+
const h = String(host).split(':')[0];
|
|
112
|
+
return domainToRegex(route.domain).test(h);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// PHP's rawurldecode(): the percent escapes are decoded, an invalid escape
|
|
116
|
+
// is left alone.
|
|
117
|
+
function rawurldecode(path) {
|
|
118
|
+
try {
|
|
119
|
+
return decodeURIComponent(path);
|
|
120
|
+
} catch {
|
|
121
|
+
return path.replace(/%[0-9A-Fa-f]{2}/g, (m) => {
|
|
122
|
+
try { return decodeURIComponent(m); } catch { return m; }
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The path the route matches: the query string is out, the trailing slashes
|
|
128
|
+
// are trimmed and the escapes are decoded — Laravel's UriValidator does
|
|
129
|
+
// rtrim() first and rawurldecode() second.
|
|
130
|
+
function requestPath(req) {
|
|
131
|
+
let path = (req && req.path) || '/';
|
|
132
|
+
const qIdx = path.indexOf('?');
|
|
133
|
+
if (qIdx !== -1) path = path.slice(0, qIdx);
|
|
134
|
+
if (path.length > 1 && path.endsWith('/')) path = path.replace(/\/+$/, '');
|
|
135
|
+
return rawurldecode(path);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveMethod(req) {
|
|
139
|
+
let method = String((req && req.method) || 'GET').toUpperCase();
|
|
140
|
+
if (req && req.methodOverride) return String(req.methodOverride).toUpperCase();
|
|
141
|
+
const headers = (req && req.headers) || {};
|
|
142
|
+
// Laravel's form spoofing, via header equivalent; only POST may be overridden.
|
|
143
|
+
const override = headers['x-http-method-override'] || headers['X-HTTP-Method-Override'];
|
|
144
|
+
if (override && method === 'POST') return String(override).toUpperCase();
|
|
145
|
+
return method;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function methodMatches(route, method) {
|
|
149
|
+
const m = String(method || 'GET').toUpperCase();
|
|
150
|
+
const methods = (route.methods || []).map((x) => String(x).toUpperCase());
|
|
151
|
+
if (methods.indexOf(m) !== -1) return true;
|
|
152
|
+
// HEAD answered by GET routes
|
|
153
|
+
if (m === 'HEAD' && methods.indexOf('GET') !== -1) return true;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function match(table, req) {
|
|
158
|
+
const method = resolveMethod(req);
|
|
159
|
+
const path = requestPath(req);
|
|
160
|
+
|
|
161
|
+
const host = req && (req.host || req.hostname);
|
|
162
|
+
const tbl = Array.isArray(table) ? table : (table && table.routes) || [];
|
|
163
|
+
for (const route of tbl) {
|
|
164
|
+
if (!methodMatches(route, method)) continue;
|
|
165
|
+
if (!hostMatches(route, host)) continue;
|
|
166
|
+
const rx = uriToRegex(route.uri);
|
|
167
|
+
const m = rx.exec(path);
|
|
168
|
+
if (!m) continue;
|
|
169
|
+
const params = {};
|
|
170
|
+
if (m.groups) {
|
|
171
|
+
for (const k of Object.keys(m.groups)) {
|
|
172
|
+
// The path was decoded before it matched, so the capture is decoded.
|
|
173
|
+
if (m.groups[k] != null) params[k] = m.groups[k];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (route.domain && host) {
|
|
177
|
+
const dm = domainToRegex(route.domain).exec(String(host).split(':')[0]);
|
|
178
|
+
if (dm && dm.groups) for (const k of Object.keys(dm.groups)) params[k] = dm.groups[k];
|
|
179
|
+
}
|
|
180
|
+
return { route, params };
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The methods the URI accepts (Laravel's AbstractRouteCollection): the verbs
|
|
186
|
+
// (Router::$verbs, in Laravel's order) that have a route matching the URI —
|
|
187
|
+
// the method itself is left out. Laravel's router answers 405 (or the
|
|
188
|
+
// automatic OPTIONS response) when the URI matches a route but the method does
|
|
189
|
+
// not; the Allow header lists these verbs.
|
|
190
|
+
const ROUTER_VERBS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'QUERY'];
|
|
191
|
+
|
|
192
|
+
export function allowedMethods(table, req) {
|
|
193
|
+
const method = resolveMethod(req);
|
|
194
|
+
const path = requestPath(req);
|
|
195
|
+
const host = req && (req.host || req.hostname);
|
|
196
|
+
const tbl = Array.isArray(table) ? table : (table && table.routes) || [];
|
|
197
|
+
const verbs = [];
|
|
198
|
+
for (const verb of ROUTER_VERBS) {
|
|
199
|
+
if (verb === method) continue;
|
|
200
|
+
let found = false;
|
|
201
|
+
for (const route of tbl) {
|
|
202
|
+
const methods = (route.methods || []).map((m) => String(m).toUpperCase());
|
|
203
|
+
if (methods.indexOf(verb) === -1) continue;
|
|
204
|
+
if (!hostMatches(route, host)) continue;
|
|
205
|
+
let rx;
|
|
206
|
+
try {
|
|
207
|
+
rx = uriToRegex(route.uri);
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (rx.test(path)) {
|
|
212
|
+
found = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (found) verbs.push(verb);
|
|
217
|
+
}
|
|
218
|
+
return verbs;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function dispatch(req, table, handlers, env, options = {}) {
|
|
222
|
+
const m = match(table, req);
|
|
223
|
+
if (!m) return null;
|
|
224
|
+
|
|
225
|
+
const key = routeKey(m.route);
|
|
226
|
+
let handler = null;
|
|
227
|
+
if (handlers) {
|
|
228
|
+
if (typeof handlers.get === 'function') handler = handlers.get(key);
|
|
229
|
+
else if (Object.prototype.hasOwnProperty.call(handlers, key)) handler = handlers[key];
|
|
230
|
+
}
|
|
231
|
+
if (typeof handler !== 'function') return null;
|
|
232
|
+
|
|
233
|
+
const ctx = {
|
|
234
|
+
params: m.params,
|
|
235
|
+
request: options.ctxRequest ?? {
|
|
236
|
+
method: resolveMethod(req),
|
|
237
|
+
path: (req && req.path) || '/',
|
|
238
|
+
headers: (req && req.headers) || {},
|
|
239
|
+
host: (req && (req.host || req.hostname)) || null,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
Object.assign(ctx, options.ctxExtra || {});
|
|
243
|
+
const bindings = m.route && m.route.bindings;
|
|
244
|
+
if (bindings && options && options.models) {
|
|
245
|
+
ctx.models = {};
|
|
246
|
+
for (const name of Object.keys(bindings)) {
|
|
247
|
+
const b = bindings[name];
|
|
248
|
+
const modelName = b == null ? b : (b.model ?? b);
|
|
249
|
+
const Model = options.models[modelName];
|
|
250
|
+
if (!Model) {
|
|
251
|
+
const err = new Error('unknown model: ' + String(modelName));
|
|
252
|
+
err.status = 500;
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
const column = (b == null) ? 'id' : (b.column || 'id');
|
|
256
|
+
const instance = await Model.query(options.db).where(column, ctx.params[name]).first();
|
|
257
|
+
if (!instance) {
|
|
258
|
+
const err = new Error('not found: ' + String(name));
|
|
259
|
+
err.status = 404;
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
ctx.models[name] = instance;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const res = await handler(ctx, env);
|
|
266
|
+
if (!res || typeof res.status !== 'number' || typeof res.body !== 'string') {
|
|
267
|
+
throw new Error('handler returned invalid response shape');
|
|
268
|
+
}
|
|
269
|
+
const headers = {};
|
|
270
|
+
if (res.headers && typeof res.headers === 'object') {
|
|
271
|
+
for (const k of Object.keys(res.headers)) headers[String(k).toLowerCase()] = res.headers[k];
|
|
272
|
+
}
|
|
273
|
+
return { status: res.status, headers, body: res.body };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export default { normalizeTable, routeKey, match, allowedMethods, dispatch };
|